release: bump version to 0.50.0

This commit is contained in:
rayd1o
2026-05-10 22:06:01 +08:00
parent e1984c7a35
commit 455b8360d0
80 changed files with 10936 additions and 298 deletions

View File

@@ -236,6 +236,8 @@ bun run build
推荐按下面顺序排查和配置。
端口占用、`iphlpsvc` / portproxy、摄像头和依赖问题的集中排障入口见 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。
### 1. 在 WSL 中启动服务
```bash

View File

@@ -1 +1 @@
0.49.0
0.50.0

View File

@@ -32,6 +32,15 @@ AI_API_KEY=sk-cp-change-me
AI_MAX_TOKENS=1200
AI_ANTHROPIC_VERSION=2023-06-01
# Optional provider-specific keys used by Settings fallback before AI_API_KEY
# MINIMAX_API_KEY=sk-cp-change-me
# OPENAI_API_KEY=sk-change-me
# ANTHROPIC_API_KEY=sk-ant-change-me
# DEEPSEEK_API_KEY=sk-change-me
# DASHSCOPE_API_KEY=sk-change-me
# MOONSHOT_API_KEY=sk-change-me
# OPENROUTER_API_KEY=sk-or-change-me
# OpenAI-compatible example (vLLM / LM Studio / One API / local gateway)
# AI_PROVIDER=openai
# AI_PROVIDER_API=openai-completions

View File

@@ -14,10 +14,13 @@ from app.models.bgp_incident import BGPIncident
from app.models.bgp_observation import BGPObservation
from app.models.user import User
from app.services.bgp_collector_locations import (
build_bgp_collector_location_query,
collect_bgp_collector_location_candidates,
get_bgp_collector_location_dict,
)
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
router = APIRouter()
@@ -282,6 +285,7 @@ async def collect_bgp_collector_location(
collector_id: str,
payload: CollectBGPCollectorLocationRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Run the shared location pipeline for a BGP route collector.
@@ -307,6 +311,34 @@ async def collect_bgp_collector_location(
country=country,
operator=operator,
)
llm_failure_reason = None
if not candidates:
query = build_bgp_collector_location_query(
collector=collector_id,
site=site,
city=city,
country=country,
operator=operator,
)
try:
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,
)
except Exception as exc:
llm_result = None
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
attempted_queries = [
*attempted_queries,
f"llm_factcheck:bgp_collector:{collector_id or 'unknown'}",
]
if llm_result is not None:
attempted_queries = [*attempted_queries, *llm_result.attempted_queries]
candidates = llm_result.candidates
llm_failure_reason = llm_result.failure_reason
context = {
"collector": collector_id,
@@ -327,6 +359,7 @@ async def collect_bgp_collector_location(
),
"candidates": [],
"attempted_queries": list(attempted_queries),
"llm_failure_reason": llm_failure_reason,
"context": context,
}

View File

@@ -1,9 +1,11 @@
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, EmailStr, Field
from dotenv import dotenv_values
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,6 +20,7 @@ from app.models.datasource_config import DataSourceConfig
from app.models.system_setting import SystemSetting
from app.models.user import User
from app.models.vessel import AISSourceHealth
from app.schemas.ai import SituationalAnalysisRequest
from app.services.barentswatch import (
BarentsWatchConfig,
check_barentswatch_config,
@@ -36,6 +39,7 @@ from app.services.datasource_connectivity import (
)
from app.services.ai_client import AIProviderClient, get_ai_provider_client
from app.services.llm_provider_catalog import (
FALLBACK_LLM_PROVIDER_PRESETS,
get_fallback_llm_provider_preset,
list_fallback_llm_provider_presets,
refresh_llm_provider_preset,
@@ -70,13 +74,8 @@ DEFAULT_SETTINGS = {
"ai_provider": {
"service_url": "",
"service_token": "",
"provider": "minimax",
"provider_api": "anthropic-messages",
"base_url": "https://api.minimaxi.com/anthropic",
"model": "MiniMax-M2.7",
"api_key": "",
"max_tokens": 1200,
"anthropic_version": "2023-06-01",
"default_provider": "minimax",
"providers": {},
"timeout_seconds": 60,
"retry_attempts": 2,
}
@@ -141,6 +140,7 @@ class TVSettingsUpdate(BaseModel):
class AIProviderIntegrationUpdate(BaseModel):
service_url: str = ""
service_token: Optional[str] = None
default_provider: Optional[str] = None
provider: str = Field(default="minimax", max_length=80)
provider_api: str = Field(default="anthropic-messages", max_length=80)
base_url: str = Field(default="", max_length=500)
@@ -216,17 +216,226 @@ async def save_setting_payload(db: AsyncSession, category: str, payload: dict) -
return merge_with_defaults(category, record.payload)
def _mask_secret(value: Optional[str]) -> dict:
AI_PROVIDER_ENV_FILE = Path(__file__).resolve().parents[4] / "aiprovider" / ".env"
def _mask_secret(value: Optional[str], source: str = "") -> dict:
if not value:
return {"configured": False, "preview": ""}
return {"configured": False, "preview": "", "source": source}
text = str(value)
if "-" in text:
prefix = text.split("-", 1)[0] + "-"
preview = prefix + ("*" * max(len(text) - len(prefix), 1))
else:
prefix_len = min(4, len(text))
preview = text[:prefix_len] + ("*" * max(len(text) - prefix_len, 1))
return {"configured": True, "preview": preview}
preview = "*" * len(text)
return {"configured": True, "preview": preview, "source": source}
def _normalize_provider_id(provider: Optional[str]) -> str:
return (provider or "minimax").strip().lower() or "minimax"
def _get_provider_preset(provider: str) -> dict:
try:
return get_fallback_llm_provider_preset(provider)
except ValueError:
return {
"provider": provider,
"provider_api": "openai-completions",
"base_url": "",
"model": "",
"models": [],
"api_key_env": "",
}
def _read_ai_provider_env_file() -> dict[str, str]:
if not AI_PROVIDER_ENV_FILE.exists():
return {}
return {
key: str(value)
for key, value in dotenv_values(AI_PROVIDER_ENV_FILE).items()
if value is not None
}
def _resolve_env_secret(*names: str) -> tuple[str, str]:
env_file_values = _read_ai_provider_env_file()
for name in names:
if not name:
continue
value = env_file_values.get(name)
if value:
return value, "env_file"
return "", ""
def _provider_defaults(provider: str) -> dict:
preset = _get_provider_preset(provider)
return {
"provider": provider,
"provider_api": preset.get("provider_api") or "openai-completions",
"base_url": preset.get("base_url") or "",
"model": preset.get("model") or "",
"api_key": "",
"max_tokens": (
1200 if preset.get("provider_api") == "anthropic-messages" else 4096
),
"anthropic_version": "2023-06-01",
}
def _normalize_ai_provider_payload(ai_payload: dict | None) -> dict:
raw = dict(ai_payload or {})
default_provider = _normalize_provider_id(raw.get("default_provider") or raw.get("provider"))
providers = {
_normalize_provider_id(provider): dict(config or {})
for provider, config in (raw.get("providers") or {}).items()
if provider
}
legacy_fields = {
key: raw.get(key)
for key in (
"provider_api",
"base_url",
"model",
"api_key",
"max_tokens",
"anthropic_version",
)
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_provider_id(provider)
normalized_providers[provider_id] = {
**_provider_defaults(provider_id),
**dict(config or {}),
"provider": provider_id,
}
if default_provider not in normalized_providers:
normalized_providers[default_provider] = _provider_defaults(default_provider)
return {
"service_url": raw.get("service_url") or "",
"service_token": raw.get("service_token") or "",
"default_provider": default_provider,
"providers": normalized_providers,
"timeout_seconds": int(raw.get("timeout_seconds") or 60),
"retry_attempts": int(raw.get("retry_attempts") or 2),
}
def _resolve_provider_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_provider_preset(provider)
api_key_env = preset.get("api_key_env") or ""
return _resolve_env_secret(api_key_env, "AI_API_KEY")
def _resolve_service_token(ai_payload: dict) -> tuple[str, str]:
saved_token = ai_payload.get("service_token") or ""
if saved_token:
return str(saved_token), "runtime"
token, source = _resolve_env_secret("AI_PROVIDER_SERVICE_TOKEN")
if token:
return token, source
if app_settings.AI_PROVIDER_SERVICE_TOKEN:
return app_settings.AI_PROVIDER_SERVICE_TOKEN, "backend_env"
return "", ""
def _is_secret_placeholder(value: Optional[str], current_preview: str = "") -> bool:
if value in (None, ""):
return True
text = str(value).strip()
if not text:
return True
return text == current_preview or text.startswith("••••") or "*" in text
def _build_ai_provider_payload(current_payload: dict, update: AIProviderIntegrationUpdate) -> dict:
current_ai = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
provider_id = _normalize_provider_id(update.default_provider or update.provider)
current_providers = {
provider: dict(config or {})
for provider, config in current_ai.get("providers", {}).items()
}
current_provider = current_providers.get(provider_id) or _provider_defaults(provider_id)
current_api_key, current_api_key_source = _resolve_provider_api_key(provider_id, current_provider)
current_api_key_preview = _mask_secret(current_api_key, current_api_key_source)["preview"]
provider_payload = {
**_provider_defaults(provider_id),
**current_provider,
"provider": provider_id,
"provider_api": update.provider_api.strip()
or current_provider.get("provider_api")
or "anthropic-messages",
"base_url": update.base_url.strip(),
"model": update.model.strip(),
"max_tokens": update.max_tokens,
"anthropic_version": update.anthropic_version.strip() or "2023-06-01",
}
if not _is_secret_placeholder(update.api_key, current_api_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
current_service_token, current_service_source = _resolve_service_token(current_ai)
current_service_preview = _mask_secret(current_service_token, current_service_source)["preview"]
ai_payload = {
"service_url": update.service_url.strip()
or app_settings.AI_PROVIDER_SERVICE_URL,
"service_token": current_ai.get("service_token") or "",
"default_provider": provider_id,
"providers": current_providers,
"timeout_seconds": update.timeout_seconds,
"retry_attempts": update.retry_attempts,
}
if not _is_secret_placeholder(update.service_token, current_service_preview):
ai_payload["service_token"] = str(update.service_token).strip()
return ai_payload
def _runtime_config_from_ai_payload(ai_payload: dict) -> dict:
normalized_ai = _normalize_ai_provider_payload(ai_payload)
default_provider = normalized_ai["default_provider"]
provider_config = (
normalized_ai["providers"].get(default_provider) or _provider_defaults(default_provider)
)
api_key, _api_key_source = _resolve_provider_api_key(default_provider, provider_config)
return {
"service_url": normalized_ai.get("service_url") or app_settings.AI_PROVIDER_SERVICE_URL,
"service_token": _resolve_service_token(normalized_ai)[0],
"timeout_seconds": int(
normalized_ai.get("timeout_seconds") or app_settings.AI_PROVIDER_TIMEOUT_SECONDS
),
"retry_attempts": int(
normalized_ai.get("retry_attempts") or app_settings.AI_PROVIDER_RETRY_ATTEMPTS
),
"llm_config": {
"provider": default_provider,
"provider_api": provider_config.get("provider_api") or "anthropic-messages",
"base_url": provider_config.get("base_url") or "",
"model": provider_config.get("model") or "",
"api_key": api_key,
"max_tokens": int(provider_config.get("max_tokens") or 1200),
"anthropic_version": provider_config.get("anthropic_version") or "2023-06-01",
},
}
async def get_runtime_ai_provider_config(db: AsyncSession) -> dict:
@@ -235,31 +444,7 @@ async def get_runtime_ai_provider_config(db: AsyncSession) -> dict:
"external_integrations",
runtime_record.payload if runtime_record else None,
)
ai_payload = payload.get("ai_provider") or {}
has_runtime_llm_config = bool(
runtime_record
and isinstance(runtime_record.payload, dict)
and isinstance(runtime_record.payload.get("ai_provider"), dict)
)
return {
"service_url": ai_payload.get("service_url") or app_settings.AI_PROVIDER_SERVICE_URL,
"service_token": ai_payload.get("service_token") or app_settings.AI_PROVIDER_SERVICE_TOKEN,
"timeout_seconds": int(
ai_payload.get("timeout_seconds") or app_settings.AI_PROVIDER_TIMEOUT_SECONDS
),
"retry_attempts": int(
ai_payload.get("retry_attempts") or app_settings.AI_PROVIDER_RETRY_ATTEMPTS
),
"llm_config": {
"provider": ai_payload.get("provider") or "minimax",
"provider_api": ai_payload.get("provider_api") or "anthropic-messages",
"base_url": ai_payload.get("base_url") or "https://api.minimaxi.com/anthropic",
"model": ai_payload.get("model") or "MiniMax-M2.7",
"api_key": ai_payload.get("api_key") or "",
"max_tokens": int(ai_payload.get("max_tokens") or 1200),
"anthropic_version": ai_payload.get("anthropic_version") or "2023-06-01",
} if has_runtime_llm_config else {},
}
return _runtime_config_from_ai_payload(payload.get("ai_provider") or {})
async def get_barentswatch_config_record(db: AsyncSession) -> Optional[DataSourceConfig]:
@@ -269,7 +454,32 @@ async def get_barentswatch_config_record(db: AsyncSession) -> Optional[DataSourc
async def serialize_external_integrations(db: AsyncSession) -> dict:
ai_config = await get_runtime_ai_provider_config(db)
runtime_setting = await get_setting_record(db, "external_integrations")
display_llm_config = ai_config["llm_config"] or DEFAULT_SETTINGS["external_integrations"]["ai_provider"]
raw_payload = merge_with_defaults(
"external_integrations",
runtime_setting.payload if runtime_setting else None,
)
normalized_ai = _normalize_ai_provider_payload(raw_payload.get("ai_provider") or {})
default_provider = normalized_ai["default_provider"]
providers_payload: dict[str, dict] = {}
for provider in sorted({
*FALLBACK_LLM_PROVIDER_PRESETS.keys(),
*normalized_ai["providers"].keys(),
default_provider,
}):
provider_id = _normalize_provider_id(provider)
provider_config = normalized_ai["providers"].get(provider_id) or _provider_defaults(provider_id)
api_key, api_key_source = _resolve_provider_api_key(provider_id, provider_config)
providers_payload[provider_id] = {
"provider": provider_id,
"provider_api": provider_config.get("provider_api") or "openai-completions",
"base_url": provider_config.get("base_url") or "",
"model": provider_config.get("model") or "",
"api_key": _mask_secret(api_key, api_key_source),
"max_tokens": int(provider_config.get("max_tokens") or 1200),
"anthropic_version": provider_config.get("anthropic_version") or "2023-06-01",
"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)
barentswatch_record = await get_barentswatch_config_record(db)
barentswatch_auth = barentswatch_record.auth_config if barentswatch_record else {}
barentswatch_auth = barentswatch_auth or {}
@@ -277,12 +487,14 @@ async def serialize_external_integrations(db: AsyncSession) -> dict:
return {
"ai_provider": {
"service_url": ai_config["service_url"],
"service_token": _mask_secret(ai_config["service_token"]),
"provider": display_llm_config.get("provider") or "minimax",
"service_token": _mask_secret(*_resolve_service_token(normalized_ai)),
"default_provider": default_provider,
"provider": default_provider,
"provider_api": display_llm_config.get("provider_api") or "anthropic-messages",
"base_url": display_llm_config.get("base_url") or "https://api.minimaxi.com/anthropic",
"model": display_llm_config.get("model") or "MiniMax-M2.7",
"api_key": _mask_secret(display_llm_config.get("api_key")),
"api_key": display_llm_config.get("api_key") or _mask_secret(None),
"providers": providers_payload,
"max_tokens": int(display_llm_config.get("max_tokens") or 1200),
"anthropic_version": display_llm_config.get("anthropic_version") or "2023-06-01",
"timeout_seconds": ai_config["timeout_seconds"],
@@ -305,29 +517,7 @@ async def save_external_integrations_payload(
update: ExternalIntegrationsUpdate,
) -> dict:
current_payload = await get_setting_payload(db, "external_integrations")
current_ai = current_payload.get("ai_provider") or {}
ai_payload = {
"service_url": update.ai_provider.service_url.strip()
or app_settings.AI_PROVIDER_SERVICE_URL,
"service_token": current_ai.get("service_token") or "",
"provider": update.ai_provider.provider.strip() or "minimax",
"provider_api": update.ai_provider.provider_api.strip() or "anthropic-messages",
"base_url": update.ai_provider.base_url.strip(),
"model": update.ai_provider.model.strip(),
"api_key": current_ai.get("api_key") or "",
"max_tokens": update.ai_provider.max_tokens,
"anthropic_version": update.ai_provider.anthropic_version.strip() or "2023-06-01",
"timeout_seconds": update.ai_provider.timeout_seconds,
"retry_attempts": update.ai_provider.retry_attempts,
}
if update.ai_provider.clear_service_token:
ai_payload["service_token"] = ""
elif update.ai_provider.service_token not in (None, ""):
ai_payload["service_token"] = update.ai_provider.service_token
if update.ai_provider.clear_api_key:
ai_payload["api_key"] = ""
elif update.ai_provider.api_key not in (None, ""):
ai_payload["api_key"] = update.ai_provider.api_key
ai_payload = _build_ai_provider_payload(current_payload, update.ai_provider)
await save_setting_payload(db, "external_integrations", {"ai_provider": ai_payload})
@@ -530,6 +720,85 @@ async def connect_barentswatch_integration(
return {**result, "connected": False}
@router.post("/integrations/ai-provider/connect")
async def connect_ai_provider_integration(
payload: AIProviderIntegrationUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_payload = await get_setting_payload(db, "external_integrations")
draft_ai_payload = _build_ai_provider_payload(current_payload, payload)
runtime_config = _runtime_config_from_ai_payload(draft_ai_payload)
client = AIProviderClient(
service_url=runtime_config["service_url"],
service_token=runtime_config["service_token"],
timeout=runtime_config["timeout_seconds"],
retry_attempts=runtime_config["retry_attempts"],
llm_config=runtime_config.get("llm_config") or {},
)
try:
status_result = await client.get_status()
if not status_result.configured:
return {
"success": False,
"connected": False,
"message": "AI Provider 可访问,但当前 provider/model/key 未完整配置。",
"status": status_result.model_dump(),
}
analysis_result = await client.analyze(
SituationalAnalysisRequest(
title="连接测试",
objective="请用一句话回复连接可用。",
observations=["这是配置中心发起的 LLM 连接测试。"],
constraints=["回复尽量简短。"],
)
)
await save_setting_payload(db, "external_integrations", {"ai_provider": draft_ai_payload})
return {
"success": True,
"connected": True,
"message": "AI Provider 连接成功,已保存为全局默认配置。",
"status": status_result.model_dump(),
"provider": analysis_result.provider,
"model": analysis_result.model,
"integrations": await serialize_external_integrations(db),
}
except HTTPException as exc:
return {
"success": False,
"connected": False,
"message": str(exc.detail),
}
except Exception as exc:
return {
"success": False,
"connected": False,
"message": f"AI Provider 连接测试失败: {exc}",
}
@router.get("/integrations/ai-provider/secrets")
async def reveal_ai_provider_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")
ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
provider_id = _normalize_provider_id(provider or ai_payload["default_provider"])
provider_config = ai_payload["providers"].get(provider_id) or _provider_defaults(provider_id)
api_key, api_key_source = _resolve_provider_api_key(provider_id, provider_config)
service_token, service_token_source = _resolve_service_token(ai_payload)
return {
"provider": provider_id,
"api_key": api_key,
"api_key_source": api_key_source,
"service_token": service_token,
"service_token_source": service_token_source,
}
@router.get("/credential-guides/{provider}")
async def read_credential_guide(
provider: str,

View File

@@ -31,12 +31,15 @@ from app.services.cable_graph import build_graph_from_data, CableGraph, haversin
from app.services.compute_center_locations import (
RENDERABLE_PRECISIONS,
ResolutionDiagnostic,
build_compute_center_location_query,
collect_location_candidates,
refresh_compute_center_location_cache,
resolve_compute_center_location_full,
upsert_compute_center_location,
)
from app.services.ai_client import get_ai_provider_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.persistent_logs import record_system_log
from app.services.vessel_ais_aggregation import (
build_field_conflict_candidates,
@@ -1864,6 +1867,37 @@ async def collect_compute_center_location(
country=country,
record_id=record_id,
)
llm_failure_reason = None
if not candidates:
query = build_compute_center_location_query(
name=name,
source=source,
source_id=source_id,
operator=operator,
site=site,
organization=organization,
city=city,
country=country,
)
try:
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,
)
except Exception as exc:
llm_result = None
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
attempted_queries = [
*attempted_queries,
f"llm_factcheck:compute_center:{name or source_id or 'unknown'}",
]
if llm_result is not None:
attempted_queries = [*attempted_queries, *llm_result.attempted_queries]
candidates = llm_result.candidates
llm_failure_reason = llm_result.failure_reason
if not candidates:
return {
@@ -1877,6 +1911,7 @@ async def collect_compute_center_location(
),
"candidates": [],
"attempted_queries": list(attempted_queries),
"llm_failure_reason": llm_failure_reason,
"context": {
"name": name,
"operator": operator,

View File

@@ -291,9 +291,27 @@ def collect_bgp_collector_location_candidates(
site: str | None = None,
operator: str | None = None,
) -> tuple[list[LocationCandidate], list[str]]:
query = build_bgp_collector_location_query(
collector=collector,
city=city,
country=country,
site=site,
operator=operator,
)
return BGP_COLLECTOR_COLLECTION_PIPELINE.collect_candidates(query)
def build_bgp_collector_location_query(
*,
collector: str | None = None,
city: str | None = None,
country: str | None = None,
site: str | None = None,
operator: str | None = None,
) -> LocationQuery:
stored = get_bgp_collector_location_dict(collector or "")
name = coerce_str(collector) or None
query = LocationQuery(
return LocationQuery(
name=name,
aliases=tuple(filter(None, (collector,))),
city=coerce_str(city or stored.get("city")) or None,
@@ -301,6 +319,6 @@ def collect_bgp_collector_location_candidates(
extra={
"site": coerce_str(site or stored.get("site")),
"operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC",
"collector": coerce_str(collector),
},
)
return BGP_COLLECTOR_COLLECTION_PIPELINE.collect_candidates(query)

View File

@@ -713,6 +713,30 @@ def collect_location_candidates(
The unused ``source`` / ``source_id`` / ``record_id`` arguments are kept
for backward compatibility with the API handler that calls this function.
"""
query = build_compute_center_location_query(
name=name,
source=source,
source_id=source_id,
operator=operator,
site=site,
city=city,
country=country,
organization=organization,
)
return COMPUTE_CENTER_COLLECTION_PIPELINE.collect_candidates(query)
def build_compute_center_location_query(
*,
name: str | None = None,
source: str | None = None,
source_id: str | None = None,
operator: str | None = None,
site: str | None = None,
city: str | None = None,
country: str | None = None,
organization: str | None = None,
) -> LocationQuery:
name_value = coerce_str(name)
context: dict[str, str] = {
"source": coerce_str(source),
@@ -725,8 +749,7 @@ def collect_location_candidates(
"operator": coerce_str(operator or organization),
"organization": coerce_str(organization),
}
query = _context_to_query(context)
return COMPUTE_CENTER_COLLECTION_PIPELINE.collect_candidates(query)
return _context_to_query(context)
def _record_operator(metadata: dict[str, Any]) -> str | None:

View File

@@ -34,7 +34,8 @@ DOCS_METADATA: tuple[DocsMetadata, ...] = (
DocsMetadata(DOCS_README_FILENAME, DEFAULT_DOCS_SLUG, "public", "Overview", 0, "技术文档", "Technical Docs"),
DocsMetadata("quickstart.md", "quickstart", "public", "Manual", 1, "快速开始", "Quickstart"),
DocsMetadata("manual.md", "manual", "public", "Manual", 2, "Planet 使用手册", "Planet Manual"),
DocsMetadata("location-pipeline-user.md", "location-pipeline-user", "public", "Manual", 3, "Earth 位置候选采集使用手册", "Earth Location Candidate Collection User Guide"),
DocsMetadata("faq.md", "faq", "public", "Manual", 3, "常见问题", "FAQ"),
DocsMetadata("location-pipeline-user.md", "location-pipeline-user", "public", "Manual", 4, "Earth 位置候选采集使用手册", "Earth Location Candidate Collection User Guide"),
DocsMetadata("earth-frontend-context.md", "earth-frontend-context", "docs_developer", "Earth", 10, "Earth 前端结构", "Earth Frontend Context"),
DocsMetadata("earth-layer-style-reference.md", "earth-layer-style-reference", "docs_developer", "Earth", 11, "Earth 图层样式属性索引", "Earth Layer Style Reference"),
DocsMetadata("earth-render-layer-order.md", "earth-render-layer-order", "docs_developer", "Earth", 12, "Earth 渲染图层顺序", "Earth Render Layer Order"),

View File

@@ -0,0 +1,970 @@
"""LLM-backed fallback candidate generation for hard-to-resolve locations."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
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.location.models import LocationCandidate, LocationQuery
from app.services.location.resolvers.nominatim import build_default_nominatim_geocoder
from app.services.location.text import (
coerce_str,
normalize_country_text,
normalize_text,
parse_float,
)
VALID_LLM_PRECISIONS = {"precise", "site", "city"}
DEFAULT_MIN_CONFIDENCE = 0.55
MODEL_CONFIDENCE_WEIGHT = 0.25
_geocode_llm_city = build_default_nominatim_geocoder()
_LLM_LOCATION_NAME_KEYS = (
"matched_location_name",
"display_name",
"location_name",
"location",
"place",
"city",
)
_NAME_HINT_STOPWORDS = {
"ai",
"cloud",
"cluster",
"compute",
"computer",
"gpu",
"hpc",
"mercury",
"phase",
"super",
"supercomputer",
}
LLM_PRECISION_ALIASES = {
"precise": "precise",
"exact": "precise",
"coordinate": "precise",
"coordinates": "precise",
"site": "site",
"site level": "site",
"site-level": "site",
"site_level": "site",
"facility": "site",
"facility level": "site",
"city": "city",
"city level": "city",
"city-level": "city",
"city_level": "city",
}
@dataclass(frozen=True)
class LocationLLMFallbackResult:
candidates: list[LocationCandidate]
attempted_queries: list[str]
failure_reason: str | None = None
@dataclass(frozen=True)
class LocationEvidenceScore:
score: float
model_confidence: float
source_quality: float
entity_match: float
geography_match: float
precision_quality: float
conflict_penalty: float
weak_evidence_penalty: float
name_location_hint: float
summary: str
def _first_json_object(text: str) -> dict[str, Any] | None:
stripped = text.strip()
if not stripped:
return None
if stripped.startswith("```"):
stripped = re.sub(r"^```(?:json)?\s*", "", stripped, flags=re.IGNORECASE)
stripped = re.sub(r"\s*```$", "", stripped)
try:
data = json.loads(stripped)
return data if isinstance(data, dict) else None
except json.JSONDecodeError:
pass
start = stripped.find("{")
end = stripped.rfind("}")
if start < 0 or end <= start:
return None
try:
data = json.loads(stripped[start : end + 1])
except json.JSONDecodeError:
return None
return data if isinstance(data, dict) else None
def _compact_evidence(value: Any) -> str:
if isinstance(value, list):
parts = [_evidence_label(item) for item in value if _evidence_label(item)]
return "; ".join(parts[:3])
return coerce_str(value)
def _evidence_items(value: Any) -> list[dict[str, Any]]:
if isinstance(value, list):
raw_items = value
elif value in (None, ""):
raw_items = []
else:
raw_items = [value]
items: list[dict[str, Any]] = []
for item in raw_items:
if isinstance(item, dict):
items.append(dict(item))
else:
text = coerce_str(item)
if text:
items.append({"text": text})
return items
def _evidence_label(item: Any) -> str:
if isinstance(item, dict):
source = coerce_str(item.get("source") or item.get("title") or item.get("name"))
url = coerce_str(item.get("url"))
text = coerce_str(item.get("text") or item.get("quote") or item.get("summary"))
if source and url:
return f"{source} ({url})"
if source:
return source
if url:
return url
return text
return coerce_str(item)
def _normalize_llm_precision(value: Any) -> str:
text = coerce_str(value).lower()
return LLM_PRECISION_ALIASES.get(text, text)
def _detect_country_in_text(text: str) -> str:
normalized_text = normalize_text(text)
if not normalized_text:
return ""
for canonical, aliases in COUNTRY_ENTRIES:
variants = [canonical, *aliases]
for variant in variants:
normalized_variant = normalize_text(variant)
if normalized_variant and normalized_variant in normalized_text:
return canonical
return ""
def _extract_city_from_text(text: str, *, country: str | None = None) -> str:
patterns = [
r"\(([^()]{2,80})\)",
r"\blocated\s+(?:in|at)\s+([^,.;()\n]{2,80})(?:,\s*([^.;()\n]{2,80}))?",
r"\bbased\s+in\s+([^,.;()\n]{2,80})(?:,\s*([^.;()\n]{2,80}))?",
r"\b位[于於]\s*(?:[^,。;;\n]{0,40}?的\s*)?([^,。;;()\n]{2,40})",
]
normalized_country = normalize_text(country)
for pattern in patterns:
match = re.search(pattern, text, flags=re.IGNORECASE)
if not match:
continue
for group in match.groups():
candidate = coerce_str(group)
if not candidate:
continue
candidate = re.sub(r"^(?:the\s+city\s+of|city\s+of)\s+", "", candidate, flags=re.I)
candidate = candidate.strip(" -–—::,,。.;;")
if not candidate:
continue
if normalized_country and normalize_text(candidate) == normalized_country:
continue
if normalize_country(candidate):
continue
return candidate
return ""
def _payload_from_free_text(text: str, *, query: LocationQuery) -> dict[str, Any] | None:
"""Build a conservative payload when the model answered in prose.
This is deliberately small: it only extracts a country and a city/place-like
phrase. The normal scoring and geocoding gates still decide whether the
result can become a candidate.
"""
if not coerce_str(text):
return None
country = _detect_country_in_text(text) or normalize_country_text(query.country)
city = _extract_city_from_text(text, country=country)
if not city or not country:
return None
evidence_text = " ".join(coerce_str(text).split())[:500]
return {
"precision": "city",
"confidence": 0.55,
"city": city,
"country": country,
"matched_location_name": f"{city}, {country}",
"evidence": [
{
"source": "LLM prose location factcheck",
"source_type": "generic",
"entity_match": bool(
normalize_text(query.name)
and normalize_text(query.name) in normalize_text(text)
),
"text": evidence_text,
}
],
"reasoning_summary": "Location extracted from a non-JSON LLM answer.",
"parse_strategy": "free_text_location_extraction",
}
def _query_name_city_terms(query: LocationQuery) -> list[str]:
values = [
query.name,
*query.aliases,
(query.extra or {}).get("site"),
]
terms: list[str] = []
seen: set[str] = set()
for value in values:
text = coerce_str(value)
if not text:
continue
for raw_token in re.findall(r"[A-Za-z][A-Za-z.'-]{2,}|[\u4e00-\u9fff]{2,}", text):
token = raw_token.strip(" .'-")
key = normalize_text(token)
if not key or key in seen or key in _NAME_HINT_STOPWORDS:
continue
seen.add(key)
terms.append(token.title() if token.isupper() else token)
return terms[:5]
def _payload_from_query_name_geocode(query: LocationQuery) -> dict[str, Any] | None:
"""Use entity-name city hints only after LLM parsing fails.
The hint is accepted only when the derived term geocodes to a city-like
result in the query country. This keeps names such as "MUSICA Phase 1"
from becoming arbitrary coordinates while allowing "TAIPEI-1" -> Taipei.
"""
country = normalize_country_text(query.country)
if not country:
return None
for term in _query_name_city_terms(query):
geocode_query = f"{term}, {country}"
try:
result = _geocode_llm_city(geocode_query)
except Exception:
continue
if not isinstance(result, dict):
continue
latitude = parse_float(result.get("lat"))
longitude = parse_float(result.get("lon"))
if latitude in (None, 0.0) or longitude in (None, 0.0):
continue
address = result.get("address") if isinstance(result.get("address"), dict) else {}
city = (
address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality")
or address.get("suburb")
)
result_country = normalize_country_text(address.get("country") or country)
if not city or normalize_text(result_country) != normalize_text(country):
continue
if normalize_text(term) not in normalize_text(city) and normalize_text(term) not in normalize_text(result.get("display_name")):
continue
return {
"latitude": latitude,
"longitude": longitude,
"precision": "city",
"confidence": 0.50,
"city": city,
"region": address.get("state") or address.get("region"),
"country": result_country,
"matched_location_name": result.get("display_name") or geocode_query,
"evidence": [
{
"source": "Entity name city hint",
"source_type": "generic",
"entity_match": True,
"text": (
f"Derived city term '{term}' from entity name "
f"'{coerce_str(query.name)}' and verified it by geocoding."
),
}
],
"reasoning_summary": "City derived from entity name after LLM parsing failed.",
"parse_strategy": "query_name_city_hint",
"coordinate_source": "nominatim_city_fallback",
}
return None
def _extract_llm_coordinates(payload: dict[str, Any]) -> tuple[float | None, float | None]:
latitude = parse_float(
payload.get("latitude")
if payload.get("latitude") not in (None, "")
else payload.get("lat")
)
longitude = parse_float(
payload.get("longitude")
if payload.get("longitude") not in (None, "")
else (
payload.get("lon")
if payload.get("lon") not in (None, "")
else payload.get("lng")
)
)
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
return latitude, longitude
coordinates = payload.get("coordinates") or payload.get("coordinate")
if isinstance(coordinates, dict):
latitude = parse_float(
coordinates.get("latitude")
if coordinates.get("latitude") not in (None, "")
else coordinates.get("lat")
)
longitude = parse_float(
coordinates.get("longitude")
if coordinates.get("longitude") not in (None, "")
else (
coordinates.get("lon")
if coordinates.get("lon") not in (None, "")
else coordinates.get("lng")
)
)
elif isinstance(coordinates, (list, tuple)) and len(coordinates) >= 2:
first = parse_float(coordinates[0])
second = parse_float(coordinates[1])
if first is not None and second is not None:
# GeoJSON-style [lon, lat] is the common interchange format.
longitude, latitude = first, second
return latitude, longitude
def _fill_city_coordinates_from_geocoder(
payload: dict[str, Any],
*,
query: LocationQuery,
) -> tuple[dict[str, Any], str | None]:
city = coerce_str(payload.get("city") or query.city)
country = coerce_str(payload.get("country") or query.country)
geocode_queries: list[str] = []
def add_geocode_query(value: str) -> None:
cleaned = coerce_str(value)
if cleaned and cleaned not in geocode_queries:
geocode_queries.append(cleaned)
if city and country:
add_geocode_query(f"{city}, {country}")
for key in _LLM_LOCATION_NAME_KEYS:
value = payload.get(key)
if not isinstance(value, str):
continue
if country and country.lower() not in value.lower():
add_geocode_query(f"{value}, {country}")
add_geocode_query(value)
if not geocode_queries:
return payload, None
failures: list[str] = []
geocode_query = ""
result: dict[str, Any] | None = None
for candidate_query in geocode_queries:
geocode_query = candidate_query
try:
maybe_result = _geocode_llm_city(geocode_query)
except Exception as exc:
failures.append(f"{geocode_query}: {exc}")
continue
if not isinstance(maybe_result, dict):
failures.append(f"{geocode_query}: no result")
continue
latitude = parse_float(maybe_result.get("lat"))
longitude = parse_float(maybe_result.get("lon"))
if latitude in (None, 0.0) or longitude in (None, 0.0):
failures.append(f"{geocode_query}: invalid coordinates")
continue
result = maybe_result
break
if result is None:
detail = "; ".join(failures[:3]) or "no usable geocode query"
return payload, f"city geocode fallback found no usable result ({detail})"
latitude = parse_float(result.get("lat"))
longitude = parse_float(result.get("lon"))
if latitude in (None, 0.0) or longitude in (None, 0.0):
return payload, f"city geocode fallback returned invalid coordinates for '{geocode_query}'"
address = result.get("address") if isinstance(result.get("address"), dict) else {}
city = (
city
or address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality")
or address.get("suburb")
)
country = country or address.get("country")
try:
precision = _normalize_llm_precision(payload.get("precision")) or "city"
except Exception:
precision = "city"
filled = {
**payload,
"latitude": latitude,
"longitude": longitude,
"precision": precision,
"city": payload.get("city") or city,
"region": payload.get("region") or address.get("state") or address.get("region"),
"country": payload.get("country") or address.get("country") or country,
"matched_location_name": (
payload.get("matched_location_name")
or result.get("display_name")
or geocode_query
),
"coordinate_source": "nominatim_city_fallback",
}
return filled, None
def _truthy_evidence_field(item: dict[str, Any], *keys: str) -> bool:
for key in keys:
value = item.get(key)
if isinstance(value, bool):
if value:
return True
elif coerce_str(value).lower() in {"true", "yes", "exact", "strong"}:
return True
return False
def _source_quality_score(evidence_items: list[dict[str, Any]]) -> float:
best = 0.0
for item in evidence_items:
source_type = normalize_text(
item.get("source_type")
or item.get("type")
or item.get("source_kind")
or ""
)
source_text = normalize_text(
" ".join(
[
coerce_str(item.get("source")),
coerce_str(item.get("url")),
coerce_str(item.get("text")),
coerce_str(item.get("summary")),
]
)
)
combined = f"{source_type} {source_text}"
if any(token in combined for token in ("official", "government", "gov", "edu", "university")):
best = max(best, 0.35)
elif any(token in combined for token in ("database", "registry", "wikipedia", "news", "press")):
best = max(best, 0.25)
elif combined.strip():
best = max(best, 0.15)
return best
def _entity_match_score(payload: dict[str, Any], query: LocationQuery, evidence_items: list[dict[str, Any]]) -> float:
if any(
_truthy_evidence_field(item, "entity_match", "matches_entity", "name_match")
for item in evidence_items
):
return 0.25
names = [
query.name,
*query.aliases,
(query.extra or {}).get("site"),
(query.extra or {}).get("operator"),
(query.extra or {}).get("organization"),
]
needles = [normalize_text(name) for name in names if normalize_text(name)]
haystack = normalize_text(
" ".join(
[
coerce_str(payload.get("matched_location_name")),
coerce_str(payload.get("reasoning_summary")),
*[_evidence_label(item) for item in evidence_items],
]
)
)
if needles and any(needle in haystack for needle in needles):
return 0.25
return 0.0
def _geography_match_score(payload: dict[str, Any], query: LocationQuery) -> float:
city = normalize_text(payload.get("city") or query.city)
country = normalize_text(normalize_country_text(payload.get("country") or query.country))
context_country = normalize_text(normalize_country_text(query.country))
if city and country and (not context_country or country == context_country):
return 0.20
if country and (not context_country or country == context_country):
return 0.05
return 0.0
def _precision_quality_score(precision: str) -> float:
return {
"precise": 0.15,
"site": 0.12,
"city": 0.08,
}.get(precision, 0.0)
def _name_location_hint_score(payload: dict[str, Any], query: LocationQuery) -> float:
query_name = normalize_text(query.name)
city = normalize_text(payload.get("city") or query.city)
matched_name = normalize_text(payload.get("matched_location_name"))
if not query_name or not city:
return 0.0
if city in query_name or query_name in city:
return 0.07
if matched_name and (city in matched_name) and any(part in query_name for part in city.split()):
return 0.04
return 0.0
def _ambiguity_text(payload: dict[str, Any], evidence_items: list[dict[str, Any]]) -> str:
return normalize_text(
" ".join(
[
coerce_str(payload.get("ambiguity")),
coerce_str(payload.get("conflicts")),
coerce_str(payload.get("reasoning_summary")),
*[_evidence_label(item) for item in evidence_items],
]
)
)
def _conflict_penalty(payload: dict[str, Any], evidence_items: list[dict[str, Any]]) -> float:
penalty = 0.0
ambiguity_text = _ambiguity_text(payload, evidence_items)
if any(token in ambiguity_text for token in ("conflict", "contradict", "inconsistent")):
penalty += 0.35
if any(
_truthy_evidence_field(item, "has_conflict", "conflicting")
for item in evidence_items
):
penalty += 0.35
return min(penalty, 0.45)
def _weak_evidence_penalty(
payload: dict[str, Any],
evidence_items: list[dict[str, Any]],
*,
entity_match: float,
geography_match: float,
conflict_penalty: float,
) -> float:
ambiguity_text = _ambiguity_text(payload, evidence_items)
penalty = 0.0
if any(token in ambiguity_text for token in ("ambiguous", "unclear", "weak", "guess")):
penalty += 0.20
if any(_truthy_evidence_field(item, "ambiguous") for item in evidence_items):
penalty += 0.15
if conflict_penalty == 0.0 and entity_match > 0 and geography_match >= 0.20:
return min(penalty, 0.15)
return min(penalty, 0.30)
def _score_llm_location_payload(
payload: dict[str, Any],
*,
query: LocationQuery,
precision: str,
) -> LocationEvidenceScore:
model_confidence = parse_float(payload.get("confidence"))
model_confidence = min(max(model_confidence if model_confidence is not None else 0.0, 0.0), 1.0)
evidence_items = _evidence_items(payload.get("evidence"))
source_quality = _source_quality_score(evidence_items)
entity_match = _entity_match_score(payload, query, evidence_items)
geography_match = _geography_match_score(payload, query)
precision_quality = _precision_quality_score(precision)
conflict_penalty = _conflict_penalty(payload, evidence_items)
weak_evidence_penalty = _weak_evidence_penalty(
payload,
evidence_items,
entity_match=entity_match,
geography_match=geography_match,
conflict_penalty=conflict_penalty,
)
name_location_hint = _name_location_hint_score(payload, query)
score = (
model_confidence * MODEL_CONFIDENCE_WEIGHT
+ source_quality
+ entity_match
+ geography_match
+ precision_quality
+ name_location_hint
- conflict_penalty
- weak_evidence_penalty
)
score = min(max(score, 0.0), 1.0)
summary = (
f"combined={score:.2f}; model={model_confidence:.2f}; "
f"source={source_quality:.2f}; entity={entity_match:.2f}; "
f"geo={geography_match:.2f}; precision={precision_quality:.2f}; "
f"conflict={conflict_penalty:.2f}; weak={weak_evidence_penalty:.2f}; "
f"name_hint={name_location_hint:.2f}"
)
return LocationEvidenceScore(
score=score,
model_confidence=model_confidence,
source_quality=source_quality,
entity_match=entity_match,
geography_match=geography_match,
precision_quality=precision_quality,
conflict_penalty=conflict_penalty,
weak_evidence_penalty=weak_evidence_penalty,
name_location_hint=name_location_hint,
summary=summary,
)
def _candidate_from_payload(
payload: dict[str, Any],
*,
query: LocationQuery,
entity_type: str,
min_confidence: float,
) -> tuple[LocationCandidate | None, str | None]:
latitude, longitude = _extract_llm_coordinates(payload)
if latitude in (None, 0.0) or longitude in (None, 0.0):
return None, "missing, invalid, or zero latitude/longitude"
precision = _normalize_llm_precision(payload.get("precision"))
if precision not in VALID_LLM_PRECISIONS:
return None, f"precision '{payload.get('precision')}' is not precise/site/city"
city = coerce_str(payload.get("city")) or query.city or None
country = (
normalize_country_text(payload.get("country"))
or normalize_country_text(query.country)
or query.country
)
evidence_score = _score_llm_location_payload(payload, query=query, precision=precision)
if evidence_score.score < min_confidence:
return None, (
f"combined evidence score {evidence_score.score:.2f} is below minimum "
f"{min_confidence}; {evidence_score.summary}"
)
confidence = evidence_score.score
matched_location_name = (
coerce_str(payload.get("matched_location_name"))
or coerce_str(payload.get("display_name"))
or coerce_str(query.name)
or "LLM factcheck location"
)
evidence = _compact_evidence(payload.get("evidence"))
reasoning_summary = coerce_str(payload.get("reasoning_summary"))
source_note_parts = ["LLM location factcheck fallback"]
if payload.get("coordinate_source") == "nominatim_city_fallback":
source_note_parts.append("coordinates: Nominatim city fallback")
if evidence:
source_note_parts.append(f"evidence: {evidence}")
if reasoning_summary:
source_note_parts.append(f"summary: {reasoning_summary}")
source_note_parts.append(f"score: {evidence_score.summary}")
extra = query.extra or {}
matched_fields = tuple(
field
for field in ("name", "site", "operator", "organization", "city", "country")
if (
(field in {"name", "city", "country"} and getattr(query, field, None))
or coerce_str(extra.get(field))
)
) or ("llm_factcheck",)
return LocationCandidate(
latitude=float(latitude),
longitude=float(longitude),
display_name=matched_location_name,
precision=precision,
confidence=confidence,
query=f"llm_factcheck:{entity_type}:{coerce_str(query.name) or 'unknown'}",
source="llm_location_factcheck",
source_note="; ".join(source_note_parts),
matched_fields=matched_fields,
needs_confirmation=True,
city=city,
region=coerce_str(payload.get("region")) or query.region or None,
country=country or None,
matched_location_name=matched_location_name,
location_verified_at=None,
suggested_registry_entry={
"canonical_name": matched_location_name,
"aliases": list(
{
value
for value in [
coerce_str(query.name),
*[coerce_str(alias) for alias in query.aliases],
coerce_str(extra.get("operator")),
coerce_str(extra.get("site")),
]
if value
}
),
"operator": coerce_str(extra.get("operator")) or None,
"site": coerce_str(extra.get("site")) or None,
"country": country or None,
"city": city,
"region": coerce_str(payload.get("region")) or query.region or None,
"latitude": float(latitude),
"longitude": float(longitude),
"precision": precision,
"confidence": confidence,
"source_note": "; ".join(source_note_parts),
"llm_model_confidence": evidence_score.model_confidence,
"llm_combined_confidence": evidence_score.score,
"llm_score_breakdown": {
"source_quality": evidence_score.source_quality,
"entity_match": evidence_score.entity_match,
"geography_match": evidence_score.geography_match,
"precision_quality": evidence_score.precision_quality,
"conflict_penalty": evidence_score.conflict_penalty,
"weak_evidence_penalty": evidence_score.weak_evidence_penalty,
"name_location_hint": evidence_score.name_location_hint,
},
},
), None
def _normalize_llm_payload(payload: dict[str, Any]) -> dict[str, Any]:
for key in ("candidate", "location", "result"):
nested = payload.get(key)
if isinstance(nested, dict):
return nested
return payload
def _query_context(query: LocationQuery) -> dict[str, Any]:
extra = dict(query.extra or {})
return {
"name": query.name,
"aliases": list(query.aliases),
"city": query.city,
"region": query.region,
"country": query.country,
"source_latitude": query.source_latitude,
"source_longitude": query.source_longitude,
"extra": extra,
}
def _observations(query: LocationQuery, attempted_queries: Iterable[str]) -> list[str]:
extra = query.extra or {}
fields = [
("name", query.name),
("aliases", ", ".join(query.aliases)),
("site", extra.get("site")),
("operator", extra.get("operator")),
("organization", extra.get("organization")),
("city", query.city),
("region", query.region),
("country", query.country),
("source", extra.get("source")),
("source_id", extra.get("source_id")),
("collector", extra.get("collector")),
]
observations = [
f"{label}: {value}"
for label, value in fields
if coerce_str(value)
]
attempts = [coerce_str(item) for item in attempted_queries if coerce_str(item)]
if attempts:
observations.append("previous resolver attempts: " + " | ".join(attempts[:12]))
return observations
async def _repair_location_payload_from_text(
*,
provider_client: AIProviderClient,
raw_text: str,
query: LocationQuery,
entity_type: str,
) -> dict[str, Any] | None:
"""Second-pass structure repair for models that answer in prose.
The first LLM call owns the factcheck. This call is intentionally framed as
extraction/normalization only; it should not introduce new facts.
"""
if not coerce_str(raw_text):
return None
request = SituationalAnalysisRequest(
title=f"Normalize location factcheck for {entity_type}",
objective=(
"Convert the supplied location factcheck text into exactly one strict "
"JSON object. Extract only facts present in the text or original query."
),
context={
"entity_type": entity_type,
"location_query": _query_context(query),
"raw_location_factcheck_text": raw_text[:4000],
"required_json_schema": {
"latitude": "number|null",
"longitude": "number|null",
"precision": "precise|site|city",
"confidence": "number from 0 to 1",
"city": "string|null",
"region": "string|null",
"country": "string|null",
"matched_location_name": "string",
"evidence": "array of objects with source/source_type/entity_match/text/url when present",
"ambiguity": "string|null",
"reasoning_summary": "short string",
},
},
observations=[],
constraints=[
"Return only strict JSON. Do not wrap it in markdown.",
"Do not add new evidence or locations that are not present in the supplied text.",
"If exact coordinates are absent but a city and country are present, set latitude and longitude to null and precision to city.",
"Use confidence 0.55-0.70 for credible city-level text; use lower confidence for weak or ambiguous text.",
],
)
try:
response = await provider_client.analyze(request)
except Exception:
return None
payload = _first_json_object(response.content)
return _normalize_llm_payload(payload) if isinstance(payload, dict) else None
async def collect_llm_location_fallback_candidate(
*,
provider_client: AIProviderClient,
query: LocationQuery,
entity_type: str,
attempted_queries: Iterable[str] = (),
min_confidence: float = DEFAULT_MIN_CONFIDENCE,
) -> LocationLLMFallbackResult:
"""Ask the configured LLM for one fact-checked location candidate.
The result is intentionally conservative: invalid, low-confidence, or
non-city-level responses are treated as no candidate. Callers should only
use this in user-triggered collection flows.
"""
attempt = f"llm_factcheck:{entity_type}:{coerce_str(query.name) or 'unknown'}"
request = SituationalAnalysisRequest(
title=f"Location factcheck fallback for {entity_type}",
objective=(
"Return exactly one JSON object for the most likely physical location. "
"Use only fact-checkable public knowledge; return null fields rather "
"than guessing when evidence is weak."
),
context={
"entity_type": entity_type,
"location_query": _query_context(query),
"required_json_schema": {
"latitude": "number",
"longitude": "number",
"precision": "precise|site|city",
"confidence": "number from 0 to 1",
"city": "string|null",
"region": "string|null",
"country": "string|null",
"matched_location_name": "string",
"evidence": "array of short source/evidence phrases",
"evidence[].source_type": "official|government|academic|database|news|generic",
"evidence[].entity_match": "boolean when the evidence names the queried entity",
"ambiguity": "string|null describing same-name conflicts or contradictory sources",
"reasoning_summary": "short string",
},
},
observations=_observations(query, attempted_queries),
constraints=[
"Return only strict JSON. Do not wrap it in markdown.",
"Do not return country-level, regional-only, or unknown precision.",
"Do not invent coordinates. Use lower confidence when evidence is incomplete.",
"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.",
"Prefer the facility/site if known; otherwise use the best supported city.",
],
)
try:
response = await provider_client.analyze(request)
except Exception as exc:
return LocationLLMFallbackResult(
candidates=[],
attempted_queries=[attempt],
failure_reason=f"LLM location factcheck failed: {exc}",
)
payload = _first_json_object(response.content)
if payload is None:
payload = await _repair_location_payload_from_text(
provider_client=provider_client,
raw_text=response.content,
query=query,
entity_type=entity_type,
)
if payload is None:
payload = _payload_from_free_text(response.content, query=query)
if payload is None:
payload = _payload_from_query_name_geocode(query)
if payload is None:
return LocationLLMFallbackResult(
candidates=[],
attempted_queries=[attempt],
failure_reason=(
"LLM location factcheck did not return a parseable city-level "
"location fact."
),
)
payload = _normalize_llm_payload(payload)
latitude, longitude = _extract_llm_coordinates(payload)
city_geocode_failure = None
if latitude in (None, 0.0) or longitude in (None, 0.0):
payload, city_geocode_failure = _fill_city_coordinates_from_geocoder(
payload,
query=query,
)
candidate, rejection_reason = _candidate_from_payload(
payload,
query=query,
entity_type=entity_type,
min_confidence=min_confidence,
)
if candidate is None:
if city_geocode_failure and rejection_reason == "missing, invalid, or zero latitude/longitude":
rejection_reason = f"{rejection_reason}; {city_geocode_failure}"
return LocationLLMFallbackResult(
candidates=[],
attempted_queries=[attempt],
failure_reason=(
"LLM location factcheck returned no acceptable city-level candidate"
+ (f": {rejection_reason}." if rejection_reason else ".")
),
)
return LocationLLMFallbackResult(
candidates=[candidate],
attempted_queries=[attempt],
failure_reason=None,
)

View File

@@ -33,6 +33,7 @@ from app.services.playground_session_store import upsert_playground_session
STREAM_CHUNK_SIZE = 24
STREAM_INTERVAL_SECONDS = 0.08
THINKING_PREVIEW_SECONDS = 2.6
ORPHANED_RUN_MESSAGE = "后台生成任务已中断,请点击上一条用户消息的重试按钮重新生成。"
class _ActiveRun:
@@ -179,6 +180,7 @@ async def _build_thread_response(
session: PlaygroundSession,
) -> PlaygroundThreadResponse:
messages = await _list_visible_messages(db, session_id=session.id)
messages = await _reconcile_orphaned_active_messages(db, messages)
id_map = {item.id: item.public_id for item in messages}
return PlaygroundThreadResponse(
session=session_to_response(session),
@@ -186,6 +188,30 @@ async def _build_thread_response(
)
async def _reconcile_orphaned_active_messages(
db: AsyncSession,
messages: list[PlaygroundMessage],
) -> list[PlaygroundMessage]:
changed = False
for item in messages:
if item.status not in {"pending", "thinking", "answering"}:
continue
if item.public_id in _ACTIVE_RUNS:
continue
item.status = "error"
item.content = item.content or ORPHANED_RUN_MESSAGE
orphan_meta = "错误: 后台任务已中断"
if orphan_meta not in (item.meta or []):
item.meta = [*(item.meta or []), orphan_meta]
changed = True
if changed:
await db.flush()
await db.commit()
for item in messages:
await db.refresh(item)
return messages
async def get_thread(
db: AsyncSession,
*,
@@ -550,6 +576,15 @@ def _build_conversation_history(messages: Sequence[PlaygroundMessage], current_u
return history[-8:]
def _format_run_exception(exc: Exception) -> str:
if isinstance(exc, HTTPException):
detail = exc.detail
if isinstance(detail, str):
return detail
return str(detail)
return str(exc) or type(exc).__name__
async def _run_assistant_message(
*,
user_id: int,
@@ -680,13 +715,18 @@ async def _run_assistant_message(
await db.commit()
raise
except Exception as exc:
error_message = _format_run_exception(exc)
async with async_session_factory() as db:
result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == assistant_message_id))
message = result.scalar_one_or_none()
if message is not None:
message.status = "error"
message.content = message.content or "分析失败,请检查 AI Provider 配置或稍后再试。"
message.meta = [*(message.meta or []), f"错误: {type(exc).__name__}"]
message.content = message.content or f"分析失败:{error_message}"
message.meta = [
*(message.meta or []),
f"Request ID: {request_id}",
f"错误: {error_message}",
]
await db.flush()
await db.commit()
finally:

View File

@@ -2,9 +2,13 @@
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from app.api.v1 import bgp as bgp_api
from app.services import bgp_collector_locations
from app.services.location.llm_fallback import LocationLLMFallbackResult
from app.services.bgp_collector_locations import (
RIPE_RIS_COLLECTOR_COORDS,
collect_bgp_collector_location_candidates,
@@ -106,6 +110,58 @@ def test_collect_bgp_collector_candidates_uses_nominatim_when_registry_misses(mo
assert online[0].needs_confirmation is True
@pytest.mark.asyncio
async def test_collect_bgp_collector_location_uses_llm_when_candidates_empty(monkeypatch):
llm_candidate = bgp_collector_locations.LocationCandidate(
latitude=45.764,
longitude=4.8357,
display_name="Lyon, France",
precision="city",
confidence=0.74,
query="llm_factcheck:bgp_collector:rrc-mystery",
source="llm_location_factcheck",
source_note="LLM location factcheck fallback",
matched_fields=("collector",),
needs_confirmation=True,
city="Lyon",
country="France",
)
monkeypatch.setattr(
bgp_api,
"get_bgp_collector_location_dict",
lambda _collector: {},
)
monkeypatch.setattr(
bgp_api,
"collect_bgp_collector_location_candidates",
lambda **_kwargs: ([], ["Lyon, France"]),
)
async def _fallback(**_kwargs):
return LocationLLMFallbackResult(
candidates=[llm_candidate],
attempted_queries=["llm_factcheck:bgp_collector:rrc-mystery"],
)
monkeypatch.setattr(bgp_api, "get_ai_provider_client", AsyncMock(return_value=object()))
monkeypatch.setattr(bgp_api, "collect_llm_location_fallback_candidate", _fallback)
response = await bgp_api.collect_bgp_collector_location(
"rrc-mystery",
bgp_api.CollectBGPCollectorLocationRequest(city="Lyon", country="France"),
current_user=object(),
db=AsyncMock(),
)
assert response["success"] is True
assert response["best_candidate"]["source"] == "llm_location_factcheck"
assert response["best_candidate"]["needs_confirmation"] is True
assert response["attempted_queries"] == [
"Lyon, France",
"llm_factcheck:bgp_collector:rrc-mystery",
]
# ── BGP event resolver ─────────────────────────────────────────────

View File

@@ -47,6 +47,7 @@ async def test_public_catalog_only_for_anonymous_user():
"overview",
"quickstart",
"manual",
"faq",
"location-pipeline-user",
}

View File

@@ -22,6 +22,9 @@ from app.services.location import (
ResolverOutput,
SourceCoordinatesResolver,
)
from app.schemas.ai import SituationalAnalysisResponse
import app.services.location.llm_fallback as llm_fallback
from app.services.location.llm_fallback import collect_llm_location_fallback_candidate
# ── Test fixtures ────────────────────────────────────────────────────
@@ -427,3 +430,528 @@ def test_pluggability_custom_resolver_works_without_changing_pipeline():
)
assert len(candidates) == 1
assert candidates[0].source == "peeringdb_stub"
# ── LLM fallback helper ─────────────────────────────────────────────
class _FakeAIProviderClient:
def __init__(self, content: str | list[str]):
self.contents = content if isinstance(content, list) else [content]
self.calls = 0
async def analyze(self, payload, request_id=None):
self.calls += 1
content = self.contents[min(self.calls - 1, len(self.contents) - 1)]
return SituationalAnalysisResponse(
provider="test",
model="test-model",
content=content,
raw_response={},
)
@pytest.mark.asyncio
async def test_llm_location_fallback_returns_candidate_from_strict_json():
client = _FakeAIProviderClient(
json.dumps(
{
"latitude": 45.764,
"longitude": 4.8357,
"precision": "city",
"confidence": 0.74,
"city": "Lyon",
"region": "Auvergne-Rhone-Alpes",
"country": "France",
"matched_location_name": "Lyon, France",
"evidence": ["operator and city point to Lyon"],
"reasoning_summary": "Best supported city-level match.",
}
)
)
result = await collect_llm_location_fallback_candidate(
provider_client=client,
query=LocationQuery(
name="Mystery GPU Cluster",
city="Lyon",
country="France",
extra={"operator": "Mystery Operator"},
),
entity_type="compute_center",
attempted_queries=("Mystery Operator, Lyon, France",),
)
assert client.calls == 1
assert result.failure_reason is None
assert result.attempted_queries == ["llm_factcheck:compute_center:Mystery GPU Cluster"]
candidate = result.candidates[0]
assert candidate.source == "llm_location_factcheck"
assert candidate.needs_confirmation is True
assert candidate.precision == "city"
assert candidate.city == "Lyon"
@pytest.mark.asyncio
async def test_llm_location_fallback_accepts_common_precision_aliases():
client = _FakeAIProviderClient(
json.dumps(
{
"candidate": {
"latitude": 43.2389,
"longitude": 76.8897,
"precision": "city-level",
"confidence": "0.68",
"city": "Almaty",
"country": "Kazakhstan",
"matched_location_name": "Almaty, Kazakhstan",
"evidence": ["NITEC context points to Almaty"],
"reasoning_summary": "City-level fallback.",
}
}
)
)
result = await collect_llm_location_fallback_candidate(
provider_client=client,
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
entity_type="compute_center",
)
assert result.failure_reason is None
assert result.candidates[0].precision == "city"
assert result.candidates[0].confidence >= 0.55
@pytest.mark.asyncio
async def test_llm_location_fallback_accepts_lat_lng_aliases():
client = _FakeAIProviderClient(
json.dumps(
{
"lat": 51.1694,
"lng": 71.4491,
"precision": "city",
"confidence": 0.62,
"city": "Astana",
"country": "Kazakhstan",
"matched_location_name": "Astana, Kazakhstan",
"evidence": [
{
"source": "Official source",
"source_type": "official",
"entity_match": True,
"text": "Alem.Cloud is in Astana.",
}
],
}
)
)
result = await collect_llm_location_fallback_candidate(
provider_client=client,
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
entity_type="compute_center",
)
assert result.failure_reason is None
assert result.candidates[0].latitude == pytest.approx(51.1694)
assert result.candidates[0].longitude == pytest.approx(71.4491)
@pytest.mark.asyncio
async def test_llm_location_fallback_geocodes_city_when_coordinates_missing(monkeypatch):
monkeypatch.setattr(
llm_fallback,
"_geocode_llm_city",
lambda query: {
"lat": "51.1694",
"lon": "71.4491",
"display_name": "Astana, Kazakhstan",
"address": {"city": "Astana", "country": "Kazakhstan"},
},
)
client = _FakeAIProviderClient(
json.dumps(
{
"precision": "city",
"confidence": 0.62,
"city": "Astana",
"country": "Kazakhstan",
"matched_location_name": "Astana, Kazakhstan",
"evidence": [
{
"source": "Official source",
"source_type": "official",
"entity_match": True,
"text": "Alem.Cloud is in Astana.",
}
],
}
)
)
result = await collect_llm_location_fallback_candidate(
provider_client=client,
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
entity_type="compute_center",
)
assert result.failure_reason is None
candidate = result.candidates[0]
assert candidate.latitude == pytest.approx(51.1694)
assert candidate.longitude == pytest.approx(71.4491)
assert "Nominatim city fallback" in candidate.source_note
@pytest.mark.asyncio
async def test_llm_location_fallback_geocodes_matched_location_without_city(monkeypatch):
def _fake_geocode(query):
if "Falun" not in query:
return None
return {
"lat": "60.6065",
"lon": "15.6355",
"display_name": "Falun, Dalarna County, Sweden",
"address": {"city": "Falun", "state": "Dalarna County", "country": "Sweden"},
}
monkeypatch.setattr(llm_fallback, "_geocode_llm_city", _fake_geocode)
client = _FakeAIProviderClient(
json.dumps(
{
"precision": "city",
"confidence": 0.64,
"country": "Sweden",
"matched_location_name": "Falun, Sweden",
"evidence": [
{
"source": "Credible public source",
"source_type": "news",
"entity_match": True,
"text": "DeepL Mercury supercomputer is located in Falun.",
}
],
}
)
)
result = await collect_llm_location_fallback_candidate(
provider_client=client,
query=LocationQuery(name="DeepL Mercury", country="Sweden"),
entity_type="compute_center",
)
assert result.failure_reason is None
candidate = result.candidates[0]
assert candidate.city == "Falun"
assert candidate.country == "瑞典"
assert candidate.latitude == pytest.approx(60.6065)
assert candidate.longitude == pytest.approx(15.6355)
@pytest.mark.asyncio
async def test_llm_location_fallback_repairs_non_json_answer(monkeypatch):
monkeypatch.setattr(
llm_fallback,
"_geocode_llm_city",
lambda query: {
"lat": "25.033",
"lon": "121.5654",
"display_name": "Taipei, Taiwan",
"address": {"city": "Taipei", "country": "Taiwan"},
},
)
client = _FakeAIProviderClient(
[
"TAIPEI-1 appears to be located in Taipei, Taiwan, based on NVIDIA context.",
json.dumps(
{
"latitude": None,
"longitude": None,
"precision": "city",
"confidence": 0.62,
"city": "Taipei",
"country": "Taiwan",
"matched_location_name": "Taipei, Taiwan",
"evidence": [
{
"source": "NVIDIA context",
"source_type": "generic",
"entity_match": True,
"text": "TAIPEI-1 appears to be located in Taipei.",
}
],
"reasoning_summary": "City-level location extracted from prose.",
}
),
]
)
result = await collect_llm_location_fallback_candidate(
provider_client=client,
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
entity_type="compute_center",
)
assert client.calls == 2
assert result.failure_reason is None
assert result.candidates[0].city == "Taipei"
assert result.candidates[0].source == "llm_location_factcheck"
@pytest.mark.asyncio
async def test_llm_location_fallback_accepts_taipei_name_hint_with_weak_wording(monkeypatch):
monkeypatch.setattr(
llm_fallback,
"_geocode_llm_city",
lambda query: {
"lat": "25.033",
"lon": "121.5654",
"display_name": "Taipei, Taiwan",
"address": {"city": "Taipei", "country": "Taiwan"},
},
)
client = _FakeAIProviderClient(
json.dumps(
{
"latitude": None,
"longitude": None,
"precision": "city",
"confidence": 0.43,
"city": "Taipei",
"country": "Taiwan",
"matched_location_name": "Taipei, Taiwan",
"evidence": [
{
"source": "NVIDIA context",
"source_type": "generic",
"entity_match": True,
"text": "TAIPEI-1 points to Taipei city-level placement.",
}
],
"reasoning_summary": "Weak city-level evidence, but the entity name and geography align.",
}
)
)
result = await collect_llm_location_fallback_candidate(
provider_client=client,
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
entity_type="compute_center",
)
assert result.failure_reason is None
candidate = result.candidates[0]
assert candidate.city == "Taipei"
assert candidate.confidence >= 0.55
breakdown = candidate.suggested_registry_entry["llm_score_breakdown"]
assert breakdown["weak_evidence_penalty"] <= 0.15
assert breakdown["conflict_penalty"] == 0
assert breakdown["name_location_hint"] > 0
@pytest.mark.asyncio
async def test_llm_location_fallback_geocodes_city_from_entity_name_when_llm_unparseable(monkeypatch):
def _fake_geocode(query):
if query != "Taipei, 中国(台湾)":
return None
return {
"lat": "25.033",
"lon": "121.5654",
"display_name": "Taipei, Taiwan",
"address": {"city": "Taipei", "country": "Taiwan"},
}
monkeypatch.setattr(llm_fallback, "_geocode_llm_city", _fake_geocode)
client = _FakeAIProviderClient(["not a location answer", "still not json"])
result = await collect_llm_location_fallback_candidate(
provider_client=client,
query=LocationQuery(name="TAIPEI-1", country="中国(台湾)"),
entity_type="compute_center",
)
assert client.calls == 2
assert result.failure_reason is None
candidate = result.candidates[0]
assert candidate.city == "Taipei"
assert candidate.latitude == pytest.approx(25.033)
assert candidate.longitude == pytest.approx(121.5654)
assert "Entity name city hint" in candidate.source_note
@pytest.mark.asyncio
async def test_llm_location_fallback_extracts_city_from_non_json_when_repair_fails(monkeypatch):
monkeypatch.setattr(
llm_fallback,
"_geocode_llm_city",
lambda query: {
"lat": "60.6065",
"lon": "15.6355",
"display_name": "Falun, Sweden",
"address": {"city": "Falun", "country": "Sweden"},
},
)
client = _FakeAIProviderClient(
[
"DeepL Mercury 超級電腦位於瑞典的 法倫 (Falun)。",
"still not json",
]
)
result = await collect_llm_location_fallback_candidate(
provider_client=client,
query=LocationQuery(name="DeepL Mercury", country="Sweden"),
entity_type="compute_center",
)
assert client.calls == 2
assert result.failure_reason is None
assert result.candidates[0].city == "Falun"
assert result.candidates[0].needs_confirmation is True
@pytest.mark.asyncio
async def test_llm_location_fallback_combines_model_score_with_evidence_score():
client = _FakeAIProviderClient(
json.dumps(
{
"latitude": 51.1694,
"longitude": 71.4491,
"precision": "city",
"confidence": 0.38,
"city": "Astana",
"country": "Kazakhstan",
"matched_location_name": "Astana, Kazakhstan",
"evidence": [
{
"source": "Kazakhstan National Supercomputing Center",
"url": "https://example.test/alem-cloud",
"source_type": "official",
"entity_match": True,
"text": "Alem.Cloud is located in Astana.",
}
],
"reasoning_summary": "Evidence supports city-level location but not exact facility coordinates.",
}
)
)
result = await collect_llm_location_fallback_candidate(
provider_client=client,
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
entity_type="compute_center",
)
assert result.failure_reason is None
candidate = result.candidates[0]
assert candidate.city == "Astana"
assert candidate.confidence >= 0.55
assert candidate.suggested_registry_entry["llm_model_confidence"] == pytest.approx(0.38)
assert candidate.suggested_registry_entry["llm_combined_confidence"] == pytest.approx(
candidate.confidence
)
@pytest.mark.asyncio
async def test_llm_location_fallback_rejects_low_combined_score():
result = await collect_llm_location_fallback_candidate(
provider_client=_FakeAIProviderClient(
json.dumps(
{
"latitude": 51.1694,
"longitude": 71.4491,
"precision": "city",
"confidence": 0.38,
"city": "Astana",
"country": "Kazakhstan",
"matched_location_name": "Astana, Kazakhstan",
"evidence": ["some page mentions Kazakhstan"],
"reasoning_summary": "Weak and ambiguous city evidence.",
"ambiguity": "weak city evidence",
}
)
),
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
entity_type="compute_center",
)
assert result.candidates == []
assert "combined evidence score" in result.failure_reason
assert "below minimum 0.55" in result.failure_reason
@pytest.mark.asyncio
async def test_llm_location_fallback_rejects_explicit_conflicts():
result = await collect_llm_location_fallback_candidate(
provider_client=_FakeAIProviderClient(
json.dumps(
{
"latitude": 25.033,
"longitude": 121.5654,
"precision": "city",
"confidence": 0.70,
"city": "Taipei",
"country": "Taiwan",
"matched_location_name": "Taipei, Taiwan",
"evidence": [
{
"source": "Conflicting source",
"source_type": "generic",
"entity_match": True,
"has_conflict": True,
"text": "One source says Taipei, another contradicts it.",
}
],
"reasoning_summary": "Conflicting evidence prevents confirmation.",
}
)
),
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
entity_type="compute_center",
)
assert result.candidates == []
assert "conflict=" in result.failure_reason
@pytest.mark.asyncio
@pytest.mark.parametrize(
"content",
[
"not json",
json.dumps({"latitude": 0, "longitude": 0, "precision": "city", "confidence": 0.9}),
json.dumps({"latitude": 45, "longitude": 4, "precision": "country", "confidence": 0.9}),
json.dumps({"latitude": 45, "longitude": 4, "precision": "city", "confidence": 0.2}),
],
)
async def test_llm_location_fallback_rejects_unsafe_outputs(content):
result = await collect_llm_location_fallback_candidate(
provider_client=_FakeAIProviderClient(content),
query=LocationQuery(name="Unsafe", country="France"),
entity_type="compute_center",
)
assert result.candidates == []
assert result.failure_reason
assert result.attempted_queries == ["llm_factcheck:compute_center:Unsafe"]
@pytest.mark.asyncio
async def test_llm_location_fallback_failure_explains_rejection_reason():
result = await collect_llm_location_fallback_candidate(
provider_client=_FakeAIProviderClient(
json.dumps({
"latitude": 45,
"longitude": 4,
"precision": "region",
"confidence": 0.9,
})
),
query=LocationQuery(name="Unsafe", country="France"),
entity_type="compute_center",
)
assert result.candidates == []
assert "precision" in result.failure_reason
assert "region" in result.failure_reason

View File

@@ -0,0 +1,242 @@
import json
import pytest
from motion_agent.cameras import (
MotionAgentCameraError,
MotionAgentDependencyError,
UrlCameraInput,
UrlCameraSpec,
UsbCameraInput,
UsbCameraSpec,
)
import motion_agent.cameras as motion_cameras
from motion_agent.config import MotionAgentConfig
from motion_agent.events import GestureEvent, HeartbeatEvent, SkeletonEvent, SkeletonJoint
from motion_agent.recognizer import GestureObservation
from motion_agent.server import MotionAgentServer
from motion_agent.state import GestureStateMachine
from motion_agent import cli as motion_cli
def test_gesture_event_serializes_stable_protocol_fields():
event = GestureEvent(
gesture="rotate_left",
confidence=0.91,
intensity=0.75,
timestamp_ms=1000,
seq=7,
mode="single",
)
payload = json.loads(event.to_json())
assert payload["type"] == "gesture"
assert payload["gesture"] == "rotate_left"
assert payload["phase"] == "discrete"
assert payload["confidence"] == 0.91
assert payload["intensity"] == 0.75
assert payload["timestamp_ms"] == 1000
assert payload["seq"] == 7
assert payload["source"] == "motion-agent"
assert payload["mode"] == "single"
assert payload["payload"] == {}
def test_state_machine_ignores_low_confidence_observations():
state = GestureStateMachine(confidence_threshold=0.8, cooldown_ms=400)
event = state.accept(
GestureObservation(
gesture="confirm",
confidence=0.79,
intensity=1,
timestamp_ms=1000,
)
)
assert event is None
def test_state_machine_applies_per_gesture_cooldown():
state = GestureStateMachine(confidence_threshold=0.7, cooldown_ms=400)
first = state.accept(
GestureObservation("rotate_right", confidence=0.9, intensity=0.8, timestamp_ms=1000)
)
repeated = state.accept(
GestureObservation("rotate_right", confidence=0.95, intensity=0.9, timestamp_ms=1200)
)
later = state.accept(
GestureObservation("rotate_right", confidence=0.95, intensity=0.9, timestamp_ms=1500)
)
assert first is not None
assert first.seq == 1
assert repeated is None
assert later is not None
assert later.seq == 2
def test_motion_server_status_includes_dry_run_camera_and_heartbeat():
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
status = json.loads(server.status_event().to_json())
heartbeat = json.loads(HeartbeatEvent(timestamp_ms=123).to_json())
assert status["type"] == "status"
assert status["camera_count"] == 1
assert status["active_camera_ids"] == ["dry-run:null-camera"]
assert status["recognizer"] == "dry-run"
assert heartbeat == {
"timestamp_ms": 123,
"source": "motion-agent",
"type": "heartbeat",
}
def test_skeleton_event_serializes_without_raw_image_fields():
event = SkeletonEvent(
joints=[SkeletonJoint("left_wrist", 0.42, 0.61, 0.98)],
bones=[("left_shoulder", "left_elbow"), ("left_elbow", "left_wrist")],
matched_gesture="rotate_left",
confidence=0.91,
camera_id="usb:0",
timestamp_ms=1000,
mode="single",
)
payload = json.loads(event.to_json())
assert payload["type"] == "skeleton"
assert payload["matched_gesture"] == "rotate_left"
assert payload["confidence"] == 0.91
assert payload["camera_id"] == "usb:0"
assert payload["joints"] == [
{"id": "left_wrist", "x": 0.42, "y": 0.61, "confidence": 0.98}
]
assert payload["bones"] == [["left_shoulder", "left_elbow"], ["left_elbow", "left_wrist"]]
assert "image" not in payload
assert "frame" not in payload
def test_dry_run_recognizer_produces_debug_skeleton():
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
skeleton = server.recognizer.debug_skeleton(
None,
camera_id="dry-run:null-camera",
mode="single",
)
assert skeleton is not None
assert skeleton.type == "skeleton"
assert skeleton.camera_id == "dry-run:null-camera"
assert skeleton.joints
assert skeleton.bones
class ServerRecognizerStub:
name = "stub"
def recognize(self, frame):
_ = frame
return None
def debug_skeleton(self, frame, **kwargs):
_ = frame, kwargs
return None
def test_motion_server_prefers_camera_urls_over_usb_indexes():
server = MotionAgentServer(
MotionAgentConfig(
dry_run=False,
camera_indexes=(0,),
camera_urls=("rtsp://camera.example/live", "http://camera.example/video"),
),
recognizer=ServerRecognizerStub(),
)
assert [camera.camera_id for camera in server.cameras] == ["url:0", "url:1"]
assert all(isinstance(camera, UrlCameraInput) for camera in server.cameras)
def test_usb_camera_reports_missing_opencv_as_readable_dependency_error(monkeypatch):
import builtins
original_import = builtins.__import__
original_exists = motion_cameras.Path.exists
def fake_import(name, *args, **kwargs):
if name == "cv2":
raise ImportError("cv2 missing")
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
monkeypatch.setattr(
motion_cameras.Path,
"exists",
lambda self: True if str(self) in {"/dev", "/dev/video0"} else original_exists(self),
)
camera = UsbCameraInput(UsbCameraSpec(index=0))
with pytest.raises(MotionAgentDependencyError, match="Add opencv-python with uv"):
camera.open()
def test_usb_camera_reports_missing_device_before_opencv_noise(monkeypatch):
original_exists = motion_cameras.Path.exists
monkeypatch.setattr(
motion_cameras.Path,
"exists",
lambda self: True if str(self) == "/dev" else False if str(self) == "/dev/video0" else original_exists(self),
)
camera = UsbCameraInput(UsbCameraSpec(index=0))
with pytest.raises(MotionAgentCameraError, match="/dev/video0"):
camera.open()
def test_url_camera_reports_unreachable_stream(monkeypatch):
class BrokenCapture:
def __init__(self, _url):
pass
def isOpened(self):
return False
class Cv2Stub:
VideoCapture = BrokenCapture
import builtins
original_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "cv2":
return Cv2Stub()
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
camera = UrlCameraInput(UrlCameraSpec(url="rtsp://camera.example/live"))
with pytest.raises(MotionAgentCameraError, match="Unable to open camera URL"):
camera.open()
@pytest.mark.asyncio
async def test_motion_agent_cli_reports_dependency_error_without_traceback(monkeypatch, capsys):
class BrokenServer:
def __init__(self, _config):
raise MotionAgentDependencyError("missing cv stack")
monkeypatch.setattr(motion_cli, "MotionAgentServer", BrokenServer)
exit_code = await motion_cli.async_main([])
captured = capsys.readouterr()
assert exit_code == 2
assert "Motion agent failed: missing cv stack" in captured.err
assert "Traceback" not in captured.err

View File

@@ -0,0 +1,169 @@
from types import SimpleNamespace
import pytest
from app.api.v1 import settings as settings_api
from app.api.v1.settings import (
AIProviderIntegrationUpdate,
_build_ai_provider_payload,
_mask_secret,
_normalize_ai_provider_payload,
_resolve_provider_api_key,
get_runtime_ai_provider_config,
)
@pytest.fixture(autouse=True)
def isolated_ai_provider_env_file(monkeypatch, tmp_path):
env_file = tmp_path / ".env"
monkeypatch.setattr(settings_api, "AI_PROVIDER_ENV_FILE", env_file)
return env_file
def test_legacy_ai_provider_payload_maps_to_provider_config():
payload = _normalize_ai_provider_payload(
{
"provider": "openai",
"provider_api": "openai-completions",
"base_url": "https://api.openai.example/v1",
"model": "gpt-test",
"api_key": "old-openai-key",
"max_tokens": 2048,
"anthropic_version": "2023-06-01",
}
)
assert payload["default_provider"] == "openai"
assert payload["providers"]["openai"]["api_key"] == "old-openai-key"
assert payload["providers"]["openai"]["model"] == "gpt-test"
assert payload["providers"]["openai"]["base_url"] == "https://api.openai.example/v1"
def test_provider_key_prefers_specific_env_file_key(isolated_ai_provider_env_file):
isolated_ai_provider_env_file.write_text(
"OPENAI_API_KEY=openai-env-file-key\nAI_API_KEY=generic-env-file-key\n",
encoding="utf-8",
)
value, source = _resolve_provider_api_key("openai", {"api_key": ""})
assert value == "openai-env-file-key"
assert source == "env_file"
def test_provider_key_falls_back_to_generic_ai_api_key(isolated_ai_provider_env_file):
isolated_ai_provider_env_file.write_text(
"AI_API_KEY=generic-env-file-key\n",
encoding="utf-8",
)
value, source = _resolve_provider_api_key("openai", {"api_key": ""})
assert value == "generic-env-file-key"
assert source == "env_file"
def test_mask_secret_without_prefix_is_fully_masked():
assert _mask_secret("plainsecret")["preview"] == "***********"
assert _mask_secret("sk-prefixed")["preview"] == "sk-********"
def test_build_payload_updates_only_selected_provider_key():
current = {
"ai_provider": {
"default_provider": "openai",
"providers": {
"openai": {
"provider": "openai",
"provider_api": "openai-completions",
"base_url": "https://api.openai.com/v1",
"model": "gpt-old",
"api_key": "openai-old-key",
"max_tokens": 4096,
"anthropic_version": "2023-06-01",
},
"minimax": {
"provider": "minimax",
"api_key": "minimax-old-key",
},
},
}
}
update = AIProviderIntegrationUpdate(
provider="openai",
provider_api="openai-completions",
base_url="https://api.openai.com/v1",
model="gpt-new",
api_key="openai-new-key",
max_tokens=8192,
)
payload = _build_ai_provider_payload(current, update)
assert payload["default_provider"] == "openai"
assert payload["providers"]["openai"]["api_key"] == "openai-new-key"
assert payload["providers"]["openai"]["model"] == "gpt-new"
assert payload["providers"]["minimax"]["api_key"] == "minimax-old-key"
def test_build_payload_keeps_saved_key_when_preview_submitted():
current = {
"ai_provider": {
"providers": {
"openai": {
"provider": "openai",
"api_key": "sk-old-secret",
},
},
}
}
update = AIProviderIntegrationUpdate(
provider="openai",
provider_api="openai-completions",
base_url="https://api.openai.com/v1",
model="gpt-test",
api_key="sk-*********",
)
payload = _build_ai_provider_payload(current, update)
assert payload["providers"]["openai"]["api_key"] == "sk-old-secret"
@pytest.mark.asyncio
async def test_runtime_config_uses_default_provider_specific_key(monkeypatch):
record = SimpleNamespace(
payload={
"ai_provider": {
"default_provider": "minimax",
"providers": {
"openai": {
"provider": "openai",
"api_key": "openai-key",
"provider_api": "openai-completions",
"base_url": "https://api.openai.com/v1",
"model": "gpt-test",
},
"minimax": {
"provider": "minimax",
"api_key": "minimax-key",
"provider_api": "anthropic-messages",
"base_url": "https://api.minimaxi.com/anthropic",
"model": "MiniMax-test",
},
},
}
}
)
async def fake_get_setting_record(_db, category):
assert category == "external_integrations"
return record
monkeypatch.setattr(settings_api, "get_setting_record", fake_get_setting_record)
runtime_config = await get_runtime_ai_provider_config(object())
assert runtime_config["llm_config"]["provider"] == "minimax"
assert runtime_config["llm_config"]["api_key"] == "minimax-key"
assert runtime_config["llm_config"]["model"] == "MiniMax-test"

View File

@@ -1,9 +1,14 @@
from datetime import datetime, timezone
from unittest.mock import AsyncMock
import pytest
from httpx import ASGITransport, AsyncClient
from app.api.v1.visualization import convert_compute_centers_to_geojson
from app.api.v1 import visualization as visualization_api
from app.api.v1.visualization import (
CollectComputeCenterLocationRequest,
convert_compute_centers_to_geojson,
)
import app.services.compute_center_locations as compute_center_locations
from app.db.session import get_db
from app.main import app
@@ -498,6 +503,102 @@ def test_collect_location_candidates_failure_returns_attempted_queries(monkeypat
assert attempted, "even on failure we record attempted queries for diagnostics"
@pytest.mark.asyncio
async def test_collect_compute_center_location_skips_llm_when_candidates_exist(monkeypatch):
candidate = compute_center_locations.LocationCandidate(
latitude=45.764,
longitude=4.8357,
display_name="Lyon",
precision="city",
confidence=0.62,
query="Lyon, France",
source="nominatim_online_geocode",
source_note="fixture",
matched_fields=("city", "country"),
needs_confirmation=True,
city="Lyon",
country="France",
)
monkeypatch.setattr(visualization_api, "_load_compute_center_record", AsyncMock(return_value=None))
monkeypatch.setattr(
visualization_api,
"collect_location_candidates",
lambda **_kwargs: ([candidate], ["Lyon, France"]),
)
async def _explode(**_kwargs):
raise AssertionError("LLM fallback should not run when a normal candidate exists")
monkeypatch.setattr(visualization_api, "collect_llm_location_fallback_candidate", _explode)
response = await visualization_api.collect_compute_center_location(
"epoch_ai_gpu-test",
CollectComputeCenterLocationRequest(
name="Mystery Cluster",
source="epoch_ai_gpu",
city="Lyon",
country="France",
),
db=AsyncMock(),
)
assert response["success"] is True
assert response["best_candidate"]["source"] == "nominatim_online_geocode"
@pytest.mark.asyncio
async def test_collect_compute_center_location_uses_llm_when_candidates_empty(monkeypatch):
llm_candidate = compute_center_locations.LocationCandidate(
latitude=45.764,
longitude=4.8357,
display_name="Lyon, France",
precision="city",
confidence=0.74,
query="llm_factcheck:compute_center:Mystery Cluster",
source="llm_location_factcheck",
source_note="LLM location factcheck fallback",
matched_fields=("name",),
needs_confirmation=True,
city="Lyon",
country="France",
)
monkeypatch.setattr(visualization_api, "_load_compute_center_record", AsyncMock(return_value=None))
monkeypatch.setattr(
visualization_api,
"collect_location_candidates",
lambda **_kwargs: ([], ["Mystery Cluster, France"]),
)
from app.services.location.llm_fallback import LocationLLMFallbackResult
async def _fallback(**_kwargs):
return LocationLLMFallbackResult(
candidates=[llm_candidate],
attempted_queries=["llm_factcheck:compute_center:Mystery Cluster"],
)
monkeypatch.setattr(visualization_api, "get_ai_provider_client", AsyncMock(return_value=object()))
monkeypatch.setattr(visualization_api, "collect_llm_location_fallback_candidate", _fallback)
response = await visualization_api.collect_compute_center_location(
"epoch_ai_gpu-test",
CollectComputeCenterLocationRequest(
name="Mystery Cluster",
source="epoch_ai_gpu",
country="France",
),
db=AsyncMock(),
)
assert response["success"] is True
assert response["best_candidate"]["source"] == "llm_location_factcheck"
assert response["best_candidate"]["needs_confirmation"] is True
assert response["attempted_queries"] == [
"Mystery Cluster, France",
"llm_factcheck:compute_center:Mystery Cluster",
]
@pytest.mark.asyncio
async def test_compute_centers_geojson_endpoint_returns_stats():
records = [

View File

@@ -8,6 +8,23 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.50.0] — 2026-05-10
Released: 2026-05-10
### ✨ Highlights
- 新增 Earth 动作捕捉双通道控制:Browser Camera 本地识别与 Motion Agent WebSocket 高级接入,并补齐调试 HUD、骨架预览和手势冷却保护。
- 新增 Motion 目标展示的 `PresentationController` 接入,动捕聚焦复用巡航卡片和 connector,同时保持 BGP/News 原巡航体验不变。
- 扩展位置候选管线与 AI Provider 兜底,支持算力中心和 BGP 观测站候选采集、保存、待定位队列与 LLM factcheck。
### Added / Fixed / Improved
- 改进 `planet.sh`:支持可选 Motion Agent 启动、摄像头 index/URL 参数、WSL 摄像头引导、端口清理细化和 AI Provider/Motion 依赖自动处理。
- Settings 与 Playground 支持多 provider AI 配置、密钥来源脱敏预览和运行时默认 provider 解析。
- Docs 新增 FAQ 入口,并同步中英文手册、Earth 前端上下文、位置管线和启动脚本文档。
- Earth 媒体面板记录直播/新闻 tab 状态,刷新后恢复用户上次选择。
---
## [0.49.0] — 2026-05-08
Released: 2026-05-08

View File

@@ -25,6 +25,9 @@
- [earth-real-terrain-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-real-terrain-plan.md)
- [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md)
- [earth-news-cruise-summary-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-cruise-summary-plan.md)
- [Earth 动作捕捉手势控制计划](/home/ray/dev/linkong/planet/docs/plans/earth-motion-capture-gesture-control-plan.md)
- [Earth 动捕交互语义 V2 计划](/home/ray/dev/linkong/planet/docs/plans/earth-motion-gesture-interaction-v2-plan.md)
- [Earth Presentation 解耦架构计划](/home/ray/dev/linkong/planet/docs/plans/earth-presentation-decoupled-architecture-plan.md)
- [earth-vessel-rendering-performance-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-rendering-performance-plan.md)
- [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md)
- [earth-interactable-layer-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-interactable-layer-plan.md)
@@ -32,6 +35,7 @@
- [Docs Gatekeeper 鉴权系统计划](/home/ray/dev/linkong/planet/docs/plans/docs-gatekeeper-auth-plan.md)
- [Location Resolver 共享管线计划](/home/ray/dev/linkong/planet/docs/plans/location-resolver-shared-pipeline-plan.md)
- [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
- [Lightweight Agent Orchestrator 与 WebSearch 证据层计划](/home/ray/dev/linkong/planet/docs/plans/agents-light-orchestrator-websearch-plan.md)
- [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md)
不适合放入这里的内容:

View File

@@ -0,0 +1,407 @@
# Lightweight Agent Orchestrator and WebSearch Evidence Plan
## Overview
Planet should not turn `aiprovider` into a general-purpose agent runtime.
`aiprovider` should remain the model gateway:
- provider compatibility
- protocol adaptation
- model authentication
- request and response normalization
Agent behavior belongs in the backend, where Planet already owns business state,
permissions, persistence, evidence records, and operator workflows.
The recommended direction is a lightweight backend Agent Orchestrator with a
controlled tool layer. The first version should use fixed workflows instead of a
free-form tool-calling loop.
## Architecture Decision
Use this boundary:
```text
aiprovider = model adapter only
backend Agent = task orchestration + tools + evidence + policy + business rules
```
This keeps model transport separate from Planet-specific behavior. It also lets
OpenAI, MiniMax, Anthropic-compatible providers, Ollama, and later providers all
reuse the same backend tools.
Recommended module shape:
```text
backend/app/services/
ai/
agent_orchestrator.py
tool_registry.py
prompts.py
schemas.py
ai_tools/
web_search.py
web_fetch.py
geo_resolve.py
internal_data_query.py
incident_query.py
evidence_store.py
situation/
bgp_analyzer.py
risk_scoring.py
event_correlator.py
alert_policy.py
aiprovider/
provider_service.py
main.py
```
## Phase 1: Controlled Workflow Agent
The first implementation should not be a full OpenClaw/Codex-style agent loop.
Planet's immediate needs are better served by explicit workflows:
1. `tutorial_refresh`
2. `geo_correction`
3. `situation_brief`
Each workflow should:
1. collect evidence with backend tools
2. normalize and store evidence
3. call `AIProviderClient` through the configured global provider/model/key
4. validate the result with Pydantic schemas
5. return a proposal, candidate, or brief instead of directly mutating critical state
For location correction, the flow should be:
```text
object name / type / current coordinate / description
-> web_search
-> web_fetch for selected results
-> geo_resolve for city/site coordinates
-> LLM structured extraction
-> schema validation and confidence scoring
-> pending review candidate
```
The LLM output must be constrained to a schema such as:
```json
{
"object_id": "string",
"object_type": "datacenter|ixp|submarine_cable|asn|city|facility|satellite",
"current_location": {
"lat": 0,
"lon": 0
},
"suggested_location": {
"lat": 0,
"lon": 0
},
"confidence": 0.82,
"reason": "short evidence-backed explanation",
"evidence": [
{
"title": "source title",
"url": "https://example.com/source",
"quote": "short supporting excerpt",
"retrieved_at": "2026-05-10T00:00:00Z"
}
],
"needs_human_review": true
}
```
The LLM may generate a suggestion, but it must not directly write final
coordinates into the dimension tables.
## Phase 2: Backend Tool Registry
Add a small Python tool interface in the backend:
```python
class ToolResult(BaseModel):
ok: bool
data: Any = None
error: str | None = None
evidence: list[dict] = []
```
Register tools through a backend registry:
```text
web_search
web_fetch
geo_resolve
internal_data_query
incident_query
evidence_store
```
Do not put WebSearch inside `aiprovider`.
Reasons:
- search is a business tool, not a model-provider feature
- search evidence must be stored and audited by the backend
- different LLM providers should share the same search pipeline
- Planet may switch between Tavily, Brave, Exa, SearXNG, or MiniMax MCP without
changing model transport
The first WebSearch implementation should be an HTTP evidence provider. Tavily is
the recommended first default because it is simple to call from the existing
`httpx` backend stack and returns LLM/RAG-friendly search results. The interface
should remain provider-neutral so Brave, Exa, SearXNG, or MiniMax MCP can be
added later.
WebSearch configuration should live under PostgreSQL `system_settings` with the
rest of external integrations:
```text
external_integrations.web_search
enabled
provider
api_key
base_url
max_results
timeout_seconds
```
Secret resolution should follow the existing settings pattern:
1. saved PostgreSQL secret
2. provider-specific environment variable, for example `TAVILY_API_KEY`
3. generic fallback `WEB_SEARCH_API_KEY`
## Phase 3: Limited Agent Loop
After the fixed workflows are stable, the backend can add a limited agent loop:
```text
LLM sees an allowed tool list
-> LLM requests a tool call
-> backend validates and executes the tool
-> tool result is added to context
-> LLM continues
-> final structured output after at most N steps
```
Guardrails:
- max tool steps: 3 to 5
- only read-only tools may run automatically
- writes go to pending review first
- all web evidence must be persisted
- all final outputs must pass schema validation
- prompts must include explicit evidence boundaries
Permission levels:
```text
L0: pure analysis, no tools
L1: read-only tools, web_search / web_fetch / internal_query
L2: proposal generation, write pending review records
L3: low-risk notifications and briefs
L4: database mutation or alert triggering, human confirmation required
```
## Situational Awareness Boundary
Planet's situational-awareness layer should not rely on the LLM as the primary
risk engine.
Use deterministic analysis for:
- anomaly type
- affected prefixes
- affected ASNs
- geographic scope
- duration
- severity score
- confidence
- related events
- raw evidence
Use the LLM for:
- readable summaries
- risk explanation
- likely impact narrative
- next recommended actions
- missing data requests
In short:
```text
deterministic services compute the score
LLM explains the evidence and options
```
Proactive alerts should be triggered by deterministic rules or scheduled jobs,
then optionally summarized by the Agent Orchestrator.
## Persistence Model
Add lightweight persistence for auditability:
```text
ai_tasks
id
task_type
status
input_json
output_json
model
created_at
finished_at
error
ai_evidence
id
task_id
source_type
title
url
snippet
content_hash
retrieved_at
credibility_score
ai_briefs
id
brief_type
severity
title
summary
evidence_ids
related_entity_ids
created_at
acknowledged_at
ai_location_suggestions
id
object_type
object_id
old_lat
old_lon
new_lat
new_lon
confidence
reason
evidence_ids
status
```
The tables can be introduced incrementally. The first implementation may start
with `ai_tasks` and `ai_evidence`, then add specialized tables when the UI needs
review queues and acknowledgement state.
## MVP Scope
The MVP should deliver three fixed capabilities:
### 1. Tutorial Refresh
Input:
- provider or tutorial topic
- current tutorial text
- known stale point, when available
Tools:
- `web_search`
- `web_fetch`
Output:
- updated Markdown
- source list
- verification status
### 2. Geo Correction
Input:
- object id
- object name
- object type
- current coordinates
- source description
Tools:
- `web_search`
- `web_fetch`
- `geo_resolve`
Output:
- `LocationCorrection` JSON
- evidence list
- pending review candidate
### 3. Situation Brief
Input:
- anomaly event
- deterministic findings
- internal data summary
Tools:
- `internal_data_query`
- optional `web_search`
Output:
- `SituationBrief` JSON
- risk explanation
- recommended actions
- missing evidence list
## Test Plan
Backend tests:
- WebSearch settings persist to `system_settings` and mask secrets in API responses.
- Env fallback resolves provider-specific keys before `WEB_SEARCH_API_KEY`.
- WebSearch provider normalizes success, empty results, 401, 429, and timeout responses.
- `tutorial_refresh` uses evidence when available and marks output unverified when no evidence exists.
- `geo_correction` returns pending review candidates and never writes final coordinates directly.
- `situation_brief` accepts deterministic findings and returns schema-valid summaries.
- Agent outputs fail closed when schema validation fails.
Frontend tests:
- WebSearch settings card shows configured state, masked key, connection test result, and save feedback.
- Candidate review UI can display evidence links and pending location suggestions.
- Situation brief UI can show evidence-backed summaries without exposing raw secrets.
Regression tests:
- existing `aiprovider` status and analysis calls remain unchanged
- current LLM provider configuration remains the global model source
- location pipeline tests continue to pass
- datasource credential guide tests continue to pass
## Assumptions
- `aiprovider` remains model-adapter-only.
- Backend tools are implemented directly in Python first; MCP support is optional and later.
- Search is evidence collection, not model transport.
- Writes to important domain tables require human confirmation.
- Deterministic analysis owns risk scores; LLM output is explanatory and evidence-backed.

View File

@@ -0,0 +1,191 @@
# Earth Motion Capture Gesture Control Plan
## Goal
为 Planet Earth 大屏和未来 3D 展示增加一套解耦的动作捕捉手势控制能力。实时输入分成两条路线:网页端可直接通过浏览器 `getUserMedia` 在本机识别;高级设备可继续使用本机 Motion Capture Edge Agent。两条路线都只输出轻量语义事件,客户端负责把“手势事件”映射到“具体交互函数”。
首版面向两颗 Logitech C1000 RGB 摄像头,但必须保持单摄像头兼容。后续任何 USB 摄像头、手机摄像头、RTSP/HTTP/WebRTC 视频源都应通过输入适配器接入,而不是改 Earth 渲染端。
## Architecture
实时链路分两种 provider,但进入 Earth 后协议一致:
```text
Browser camera -> browser-local recognizer -> Motion Provider events -> Earth control functions
Camera(s)/RTSP/HTTP -> Local Motion Capture Agent -> local WebSocket -> Motion Provider events -> Earth control functions
```
关键原则:
- 实时控制不经过 SaaS 云端。
- 实时控制不复用现有新闻、RSS、聚合数据接口。
- 浏览器 provider 和 Agent provider 都不向云端上传视频帧,只输出低带宽语义事件。
- Web/3D 客户端只消费统一事件并执行映射,不把具体输入源写进 Earth 交互逻辑。
- 双摄首版用于冗余和稳定性,不承诺完整 3D 姿态重建。
## Motion Providers
Earth 使用统一 Motion Provider 抽象:
- `browser_camera`:默认 provider。使用 `getUserMedia` 获取摄像头,在浏览器本地加载 MediaPipe Tasks Vision,输出 `gesture` / `skeleton` / `status` 事件。适合 SaaS、WSL、Windows 浏览器、大屏演示和“不安装 app”的用户。
- `motion_agent`:连接本地 Agent WebSocket。适合双摄、USB index、RTSP/HTTP 视频源、边缘设备和客户端集成。
设置项保存在 `planet.earth.settings.v2.shared.motionProvider`。`?motionProvider=browser` 强制浏览器摄像头,`?motionProvider=agent` 或 `?motionAgent=ws://...` 强制 Motion Agent。
## Motion Capture Agent
Agent 是本地 Edge 服务,职责包括:
- 读取摄像头:默认 USB index,支持单摄、双摄和未来 URL 视频源。
- 运行识别:首版使用 OpenCV + MediaPipe;识别引擎藏在接口后,未来可替换为 ONNX、TensorRT、C++ 或 Rust worker。
- 输出事件:通过 WebSocket 推送 `gesture`、`status`、`heartbeat`。
- 控制节流:负责置信度阈值、防抖、冷却时间和连续手势限频。
- 健康状态:报告摄像头数量、当前模式、识别 FPS、最近手势和错误。
- 明确失败:缺少 CV 依赖、摄像头打不开、无可用输入时给出可读错误。
Python 不应成为性能瓶颈:重计算在 OpenCV/MediaPipe 原生代码中完成,Python 只做编排、状态机和事件推送。事件消息通常小于 1KB,频率不超过 20Hz。
## Event Protocol
本地默认地址:
```text
ws://127.0.0.1:8765/ws/gestures
```
事件类型:
- `gesture`
- `status`
- `heartbeat`
手势语义:
- `rotate_left`:左挥手,地球向左旋转。
- `rotate_right`:右挥手,地球向右旋转。
- `zoom_in`:双手张开,地球放大。
- `zoom_out`:双手合拢,地球缩小。
- `confirm`:握拳或确认动作,触发当前交互确认。
最小事件字段:
```json
{
"type": "gesture",
"gesture": "rotate_left",
"phase": "discrete",
"confidence": 0.92,
"intensity": 0.8,
"timestamp_ms": 1770000000000,
"seq": 42,
"source": "motion-agent",
"mode": "single",
"payload": {}
}
```
## Earth Client Integration
Earth 前端新增 motion-control adapter:
- 连接本地 Agent WebSocket。
- 处理断线、重连、心跳和状态。
- 过滤低置信度事件。
- 将手势映射到 Earth 控制函数。
- Agent 离线时不影响普通鼠标、触摸、巡航和图层交互。
Earth 端只暴露最小动作入口:
- `applyMotionRotate(direction, intensity)`
- `applyMotionZoom(direction, intensity)`
- `applyMotionConfirm()`
动作捕捉不直接操作 Three.js 内部对象,也不修改图层业务模块。
## SaaS Strategy
未来网页端做成 SaaS 后,默认实时手势链路仍在浏览器本地完成,不走云端 RPC。高级现场设备可选本地 Agent:
```text
Browser SaaS page -> getUserMedia -> browser-local recognizer
Browser SaaS page -> local secure bridge -> Local Motion Capture Agent (advanced)
Cloud SaaS -> config/auth/status only
```
原因:
- 云端 RPC 会增加网络 RTT 和抖动。
- 上传摄像头帧有隐私和带宽风险。
- 大屏交互需要稳定体感延迟,云端只适合做配置、授权、设备状态和审计。
浏览器摄像头要求 HTTPS 或 localhost。Agent 模式在本地部署可使用 `ws://127.0.0.1:8765`;生产 HTTPS SaaS 若要接 Agent,需要补 `wss://127.0.0.1` 或等价本地安全桥接,避免浏览器混合内容限制。
## Latency Budget
目标体感延迟:
- 摄像头采集:16-33ms。
- 识别:8-25ms。
- 状态机:小于 2ms。
- 本地 WebSocket:1-5ms。
- 浏览器渲染:约 16ms。
实验室目标:从动作被识别到 Earth 响应 p95 小于 50ms;摄像头到画面响应端到端小于 120ms。
## Implementation Milestones
1. 保存本计划并注册到 `docs/plans/README.md`。
2. 新增独立 motion agent 包,提供 CLI、配置、摄像头输入抽象、事件模型和 WebSocket server。
3. 新增手势状态机,支持阈值、防抖、冷却和限频。
4. 新增 Earth motion-control provider manager,默认接浏览器摄像头 provider,可切换到 Motion Agent provider。
5. 增加 Agent 单元测试、协议测试和前端 adapter 静态验证。
6. 更新中英文用户手册和 Earth 前端开发上下文。
## Debug Mode Addition
**当前状态**:Browser Camera provider 会在调试面板中显示本地 `<video>` 预览并叠加骨架;`只显示骨骼` 可关闭视频底图。Motion Agent provider 仍只发送 `skeleton` 事件,不传原始摄像头帧。
Earth 设置中增加“动捕调试模式” switch,并增加“动捕输入源”选择。开启后,Earth 会启动当前 provider 并显示独立 HUD 调试面板。Browser Camera 模式下调试面板可以显示本机浏览器视频预览;Motion Agent 模式下只画归一化骨架点和关节连线,不传原始摄像头画面。
Motion Agent 增加 `skeleton` 事件:
```json
{
"type": "skeleton",
"camera_id": "usb:0",
"matched_gesture": "rotate_left",
"confidence": 0.91,
"joints": [{ "id": "left_wrist", "x": 0.42, "y": 0.61, "confidence": 0.98 }],
"bones": [["left_shoulder", "left_elbow"]]
}
```
调试颜色约定:
- 未匹配动作:红色骨架。
- 已匹配动作:绿色骨架,并显示匹配到的动作名。
权限先预留 `data-gatekeeper-permission="earth.motion_debug"` 标记,后续由 Gatekeeper 决定 switch 是否可见/可用。
## Test Plan
- Agent 单元测试:
- 事件模型可序列化。
- 低置信度手势被忽略。
- 冷却期内重复手势被忽略。
- 冷却后新手势可再次输出。
- 无摄像头/缺依赖时错误可读。
- Agent 协议测试:
- `gesture`、`status`、`heartbeat` 字段稳定。
- WebSocket 广播只发送语义事件。
- Earth 前端验证:
- motion-control provider manager 能消费浏览器 provider 和 Agent provider 的 mock 消息。
- browser provider 在 mock `getUserMedia` 成功时进入 active 状态。
- browser provider 在权限拒绝、无摄像头或非安全上下文时给出可读错误。
- `skeleton` 事件能触发 `earth:motion-debug-frame`。
- Agent 离线时不抛异常。
- `rotate_left/right`、`zoom_in/out`、`confirm` 映射到 Earth 动作函数。
- 文档验证:
- 计划文档存在。
- `docs/plans/README.md` 有入口。
- 中英文使用说明不互相矛盾。

View File

@@ -0,0 +1,66 @@
# Earth Motion Gesture Interaction V2 Plan
**状态**:已实现主体交互,并按实测调整。当前浏览器识别保留右手导航、头部切目标、左手上下切动捕图层、双手张开/收拢缩放;双手上举确认暂时关闭。Motion 目标展示已改为 `CruiseSequencer` + `PresentationController` 的 persistent 展示。
## Summary
把动捕从“几个单点手势触发函数”升级为一套更像大屏遥控器的交互层:右手负责地球导航,头部负责候选切换,左手上下切换动捕候选图层,双手负责缩放,调试面板支持“只显示骨骼”和暂停匹配。进入动捕模式后,Earth 自动软选中屏幕中心附近的正面可交互目标;确认动作预留为把目标升级为锁定,并用巡航/引导线式详情打开,不再模拟鼠标点击。
## Key Changes
- 手势语义 v1 固定为稳健小集:
- 修正当前左右挥手语义反向问题:手势名以用户感知方向为准,provider 层输出正确 `rotate_left` / `rotate_right`。
- 右手左/右/上/下挥控制地球水平/垂直旋转,新增 `rotate_up`、`rotate_down`。
- 双手张开/靠近明确映射为 `zoom_in` / `zoom_out`。
- 头往左/右歪新增 `focus_prev` / `focus_next`,在当前自动候选目标之间切换。
- 左手上/下挥新增 `layer_prev` / `layer_next`,切换当前动捕候选图层并聚焦该图层最近目标。
- 双手确认手势暂时关闭,避免与缩放和站姿误触混淆;协议仍保留 `confirm`。
- Motion Provider / Protocol:
- 扩展 `MOTION_GESTURES`,新增 `rotate_up`、`rotate_down`、`focus_prev`、`focus_next`、`layer_prev`、`layer_next`。
- Browser Camera provider 扩展 pose joints,保留肩/肘/腕,增加头部关键点,用于判断头歪。
- 右手作为导航手;左手独立控制动捕候选图层。
- 每类手势使用独立阈值和 cooldown,避免缩放/确认/旋转互相误触。
- Earth 交互层:
- Motion adapter 支持水平/垂直旋转和 focus 切换 callback。
- 进入动捕模式后,周期性从可交互对象中选出屏幕中心最近、位于地球正面的候选。
- 软选中目标独立于 `lockedObject`,用 hover/linked 视觉态展示,不立即打开详情。
- `focus_prev` / `focus_next` 在候选列表中切换;列表按屏幕中心距离、正面可见性、当前图层可见性排序。
- `confirm` 预留为将软选中目标升级为 locked,并打开引导线详情;若没有候选,显示状态提示。
- 调试面板:
- 在动捕 HUD / drawer 内增加“只显示骨骼”开关。
- 增加“停止匹配动作”开关:暂停 gesture 执行,但不关闭摄像头预览或骨架绘制。
- 设置持久化到 `planet.earth.settings.v2.shared.motionDebugSkeletonOnly`。
- 开启后 canvas 不绘制视频帧,只绘制深色背景 + 红/绿骨骼线;摄像头仍继续用于识别。
## Test Plan
- Browser provider 单元测试:
- 右手左/右挥输出的 `rotate_left` / `rotate_right` 与用户语义一致。
- 右手上/下挥输出 `rotate_up` / `rotate_down`。
- 双手张开输出 `zoom_in`,双手靠近输出 `zoom_out`。
- 头部左右倾斜输出 `focus_prev` / `focus_next`。
- 双手确认动作暂时不会触发。
- Motion adapter 测试:
- 新增 gesture 能通过 `normalizeGestureMessage`。
- `rotate_up/down` 调用垂直旋转逻辑。
- `focus_prev/focus_next` 调用候选切换 callback。
- `confirm` 在协议层保持兼容;浏览器 provider 当前不主动发出。
- Earth 前端验证:
- 开启动捕模式后,屏幕中心附近正面目标自动软选中。
- 头歪能在候选之间切换。
- 左手上下切换图层后会在新图层中选择最近目标并展示 persistent 引导线详情。
- 右手上下挥能旋转到南北方向目标。
- “只显示骨骼”开关持久化,刷新后状态保持。
- `bun --check` 覆盖新增/修改 Earth JS 模块,现有 motion tests 全绿。
## Assumptions
- v1 采用“右手导航、头部切候选、左手切图层、双手缩放”的交互模型;确认手势保留协议但暂时关闭浏览器识别。
- 自动选中是 soft focus,不覆盖现有 mouse locked selection;只有 `confirm` 才真正锁定目标。
- 骨骼-only 只影响调试画面,不关闭摄像头、不影响识别。
- Motion Agent 协议可以接收新增 gesture 名;旧 agent 只发旧 gesture 时仍兼容。

View File

@@ -0,0 +1,67 @@
# Earth Presentation Decoupled Architecture Plan
## Goal
把 Earth 页面里的“详情卡片、连接器、隐藏策略、跟随更新”从具体业务交互里拆出来,形成统一的 Presentation 层。第一阶段只迁 Motion 动捕展示,修复卡片被鼠标移动误隐藏、连接器 interactable 端不贴合本体的问题;BGP/News 巡航保持现状,避免改变原有轮播体验。
## Current Issues
- Motion 展示复用了巡航卡片,但隐藏判断仍散落在 `main.js` 的 hover/mousemove 分支里,导致鼠标移动时卡片可能被 `hideInfoCard()` 清掉。
- Motion 连接器 source 端目前主要使用屏幕点坐标,缺少本体视觉边界,线无法稳定贴住 marker、卫星或海缆本体。
- 卡片、连接器、目标本体和生命周期策略耦合在 adapter 内,不利于后续把点击详情、动捕、巡航统一管理。
## Phase 1 Scope
- 新增 `PresentationController`。
- Motion 使用 `PresentationController` 管理卡片、连接器和 persistent 生命周期。
- BGP/News adapter 不迁移,继续使用现有 `CruiseSequencer`、卡片位置、线动画和 dwell/advance 行为。
- InfoCard 和 CalloutConnector 继续作为底层 renderer,不重写 UI。
## Presentation Interface
`PresentationController.present(request)` 接收:
- `id`: presentation 唯一 id。
- `owner`: `motion | cruise | click | hover`。
- `card`: 提供 `render({ reveal })` 和 `hide()`。
- `connector`: 提供 `sourceProvider`、`targetProvider`、`options`,由 controller 调用 `createConnectorPath()` 和 `connector.render()`。
- `lifetime`: `persistent | timeout | sequenced`,Motion 默认 `persistent`。
- `onDismiss(reason)`: 替换、关闭、停止等清理回调。
`PresentationController.update()` 每帧重算 active connector 的 source/target anchor。`dismiss(reason)` 统一清理卡片、连接器和计时器。
## Motion Integration
- Motion adapter 不再直接管理 `showInfoCard + connector.render + hideInfoCard`。
- Motion request 使用 `owner: "motion"` 和 `lifetime: { mode: "persistent" }`。
- Motion 切目标时替换当前 presentation。
- Motion 关闭、页面销毁或用户关闭展示时 dismiss。
- Motion source anchor 使用视觉近似矩形:
- BGP / compute / vessel marker: 投影中心 + marker 尺寸近似。
- satellite: 当前卫星位置 + point size 近似。
- cable: localCenter + 小矩形近似。
## Cruise Compatibility
- BGP/News 第一阶段不迁移。
- `CruiseSequencer` 的 `auto_advance` 不改。
- 原巡航的 dwell、hide、advance、卡片固定锚点、连接器动画时序不改。
- 后续迁移 BGP/News 前必须先补回归测试,再只替换渲染层,不改排序、聚焦和时序。
## Test Plan
- `presentation-controller.test.js`
- `persistent` 不自动隐藏。
- `timeout` 按配置隐藏。
- 新 presentation 替换旧 presentation,并触发旧 `onDismiss("replace")`。
- `dismiss(reason)` 清理卡片、连接器、计时器。
- `update()` 重新获取 source/target anchor 并重绘 connector。
- Motion 手动验证:
- Motion 展示后移动鼠标,卡片不消失。
- Motion 切目标后旧卡片和旧线被替换。
- 卡片拖动、窗口 resize、地球旋转、卫星移动时 connector 两端跟随。
- source 端贴近 interactable 视觉边缘。
- 巡航回归:
- BGP/News 自动轮播、dwell、隐藏、进入下一条不变。
- 移动端 popup/drawer 行为不变。

View File

@@ -25,6 +25,7 @@ What belongs here:
- [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md): The shortest path to getting Planet running from scratch
- [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md): Complete usage guide for the console, `planet.sh`, Earth, and Docs
- [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md): Central troubleshooting entry for Windows / WSL, ports, dependencies, motion capture, credentials, and Docs permissions
- [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md): Collect and preview coordinate candidates for compute centers and BGP collectors on Earth
- [Collector Settings and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md): Data source catalog, collector settings, connectivity validation, and BarentsWatch credentials
- [Shared Location Resolution Pipeline Development Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-development.md): Backend location resolver / pipeline interfaces, registries, and extension points

View File

@@ -23,11 +23,12 @@ The recommended default is:
- business-level request shaping
- stable `/api/v1/ai/...` endpoints
- internal service-to-service authentication toward `aiprovider`
- reading the default provider, model, and per-provider keys saved in Settings, then overriding `aiprovider` `.env` defaults through internal headers
`aiprovider` is responsible for:
- model protocol adaptation
- provider selection by `.env`
- provider selection by `.env` when no backend override headers are present
- timeout and lightweight retry
- request tracing via `X-Request-ID`
@@ -85,6 +86,18 @@ Optional tracing header:
The backend will propagate `X-Request-ID` to `aiprovider` and return the same header in the response.
### Settings API
The AI settings page uses:
- `GET /api/v1/settings/integrations`
- `PUT /api/v1/settings/integrations`
- `POST /api/v1/settings/integrations/ai-provider/connect`
- `GET /api/v1/settings/integrations/ai-provider/secrets`
- `GET /api/v1/settings/integrations/ai-provider/presets`
These endpoints require an authenticated user. The `secrets` endpoint is only used when the settings page reveals a key or token; hiding the field restores the masked preview.
### AI provider internal API
Internal-only endpoints:
@@ -172,6 +185,75 @@ Both services also return:
## Configuration
### Runtime Configuration Flow
The backend Settings system owns the global LLM default. The runtime flow is:
1. Frontend or application code calls a `backend` `/api/v1/ai/...` endpoint.
2. `backend` reads `category = external_integrations` from the PostgreSQL `system_settings` table.
3. `payload.ai_provider.default_provider` selects the active provider.
4. `payload.ai_provider.providers[provider]` supplies that provider's `api_key`, `provider_api`, `base_url`, `model`, `max_tokens`, and `anthropic_version`.
5. `backend` converts those values to internal headers such as `X-AI-Provider`, `X-AI-Provider-API`, `X-AI-Base-URL`, `X-AI-API-Key`, and `X-AI-Model`.
6. `aiprovider` uses those headers to override its `.env` defaults before calling the real model vendor.
After the AI settings page saves a new default provider/model/key, Playground, alert briefs, datasource mapping generation, and other backend AI calls all use that same default.
#### Persistence Shape
AI settings are persisted in PostgreSQL, not a JSON file. The core payload shape is:
```json
{
"ai_provider": {
"service_url": "http://localhost:8010",
"service_token": "",
"default_provider": "openai",
"providers": {
"openai": {
"provider_api": "openai-completions",
"base_url": "https://api.openai.com/v1",
"model": "gpt-5.1",
"api_key": "<saved secret>",
"max_tokens": 4096,
"anthropic_version": "2023-06-01"
},
"minimax": {
"provider_api": "anthropic-messages",
"base_url": "https://api.minimaxi.com/anthropic",
"model": "MiniMax-M2.7",
"api_key": "<saved secret>",
"max_tokens": 1200,
"anthropic_version": "2023-06-01"
}
},
"timeout_seconds": 60,
"retry_attempts": 2
}
}
```
Legacy single-slot settings are mapped to `providers[provider]` on read and are written back in the new shape on save.
#### Key Fallback
Each provider has its own key slot. Resolution order is:
1. `providers[provider].api_key` in PostgreSQL
2. the provider-specific variable in `aiprovider/.env`, such as `OPENAI_API_KEY`, `MINIMAX_API_KEY`, or `ANTHROPIC_API_KEY`
3. the generic `AI_API_KEY` in `aiprovider/.env`
`.env` is only a fallback. After the settings page saves successfully, or after the connection test succeeds, PostgreSQL becomes the global default source.
#### Settings Page Behavior
- The Provider select controls the global default provider.
- The model select saves the default model for the selected provider.
- The LLM API Key field shows a masked preview while hidden; keys with a `-` prefix keep the prefix, for example `sk-********`, and keys without a prefix are fully masked.
- Clicking the eye icon fetches and displays the full plaintext value; hiding restores the masked preview.
- `Save AI Configuration` saves the current form as the global default.
- `Test Connection` uses the current form for a real model-chain test, then saves it as the global default only when the test succeeds.
- Leaving a key field empty keeps the old key; it does not delete it.
### Backend
Recommended backend `.env`:
@@ -208,6 +290,18 @@ AI_HTTP_RETRY_ATTEMPTS=2
AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。
```
Optional provider-specific keys:
```env
MINIMAX_API_KEY=sk-cp-xxxxx
OPENAI_API_KEY=sk-xxxxx
ANTHROPIC_API_KEY=sk-ant-xxxxx
DEEPSEEK_API_KEY=sk-xxxxx
DASHSCOPE_API_KEY=sk-xxxxx
MOONSHOT_API_KEY=sk-xxxxx
OPENROUTER_API_KEY=sk-or-xxxxx
```
### OpenAI-compatible example
```env

View File

@@ -81,7 +81,26 @@ Responsibilities:
- Status message
- Tooltip / error / cleanup logic
### 5. Globe and Terrain
### 5. Motion Capture Control Adapter
- [motion-control.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-control.js)
Responsibilities:
- Act as the Motion Provider manager for both `browser_camera` and `motion_agent`.
- Use browser `getUserMedia` plus local MediaPipe recognition by default; advanced setups can connect to the local Motion Capture Agent WebSocket.
- Handle browser camera permission/secure-context errors, plus Agent disconnects, reconnects, `status`, and `heartbeat` messages.
- Filter low-confidence and overly repeated gesture events.
- Map `rotate_left`, `rotate_right`, `rotate_up`, `rotate_down`, `zoom_in`, `zoom_out`, `focus_prev`, `focus_next`, `layer_prev`, `layer_next`, and `confirm` to the action entry points exposed by `main.js`.
- Parse `skeleton` debug events and dispatch `earth:motion-debug-frame`.
Gesture recognition may run locally in the browser or inside the local Agent, but neither path sends realtime camera frames to the SaaS cloud. `main.js` exposes rotation, zoom, target focus, layer switching, and confirm entry points, plus a `window.__planetEarth.motion` debug entry. The adapter starts only when `?motion=1` is present, browser local storage contains `planet-earth-motion-control-enabled=true`, or Earth settings enable Motion Debug Mode.
[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"`.
[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
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
- [terrain.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/terrain.js)
@@ -92,7 +111,7 @@ Responsibilities:
- Real terrain mesh
- Terrain tile fetch, decode, displacement, and shading
### 6. Layer Modules
### 7. Layer Modules
- [satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
- [cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
@@ -109,8 +128,12 @@ Each module is responsible for its own:
- State tracking (loaded, visible, hover, locked)
- Self-cleanup (dispose on scene destroy)
`tv.js` owns the live / aggregation-news tabs inside `media-panel`. Toolbar open and tab-switch actions write back through `earth:tv-visibility-change` and `earth:tv-tab-change`: panel visibility remains viewport-scoped at `views.<scope>.panelVisibility.media-panel`, while the active tab is stored at `shared.mediaPanelActiveTab`. Refreshing the page therefore restores the user's last live/news state. Temporary hides from `closeTransientMobileOverlays()` carry `persist:false` and do not overwrite the preference.
The compute-center layer row has a notification badge for GeoJSON `unresolved` records. The badge means "no trustworthy coordinates, cannot render on the globe"; it is different from the `?` marker drawn on already positioned but unconfirmed compute centers. Clicking the badge opens a fixed info card beside the layer panel. Row-level `采集` fetches candidates only. Header-level `一键采用` processes the queue top-to-bottom, saves the highest-confidence valid candidate, removes successful rows, renumbers the list, and dispatches `earth:compute-center-unresolved-count-change` so the badge updates immediately. When the batch ends, `earth:compute-center-location-saved` refreshes the real layer.
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.
### 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.
@@ -119,21 +142,21 @@ Vessel color and vessel type text must use the same normalized classification. `
AISStream `PositionReport` messages commonly carry live position and `MetaData.ShipName`, while vessel type usually comes from lower-frequency `ShipStaticData.Type`. The backend normalizes `MetaData.ShipName` into the vessel name and maps numeric type codes into Cargo / Tanker / Passenger / Fishing / Military where available. Missing type detail should wait for a static AIS message or the planned vessel profile enrichment; the frontend should not invent a more specific type.
### 7. HUD Panels and Search
### 8. HUD Panels and Search
- [hud-panels.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/hud-panels.js)
- [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js)
- [search.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/search.js)
- [legend.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/legend.js)
### 8. Cruise Mode
### 9. Cruise Mode
- [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js)
- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js)
The cruise sequencer handles generic logic: current target, queue order, camera focus, and dwell / hide / switch. Business modules supply target queues and content — they should not contain camera control logic.
### 9. Constants
### 10. Constants
- [constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)

306
docs/technical/en/faq.md Normal file
View File

@@ -0,0 +1,306 @@
# FAQ
This page collects common troubleshooting paths for local startup, Windows / WSL, dependencies, motion capture, credentials, and Docs permissions. Deeper background stays in the topic-specific docs; this page focuses on what to check first and which command to run.
## Startup and Ports
### What should I do when the backend port is already in use?
The error usually looks like:
```text
Backend address is already in use: 0.0.0.0:8000 / 127.0.0.1:8000 / [::1]:8000
Address already in use
```
First try:
```bash
./planet.sh restart -b
```
If the port remains occupied, start on a different backend port:
```bash
./planet.sh start -b 8001
```
In WSL, the listener may be on the Windows side rather than a Linux process. A common diagnostic line looks like:
```text
Windows listener: 0.0.0.0:8000 pid=4700 process=svchost.exe services=iphlpsvc
```
`iphlpsvc` is the Windows IP Helper service. It often hosts IPv6, tunneling, proxying, port forwarding, WSL, or developer-tool networking features. Do not start by killing that `svchost.exe`; first check whether an old portproxy rule owns the port.
From Administrator PowerShell, inspect portproxy rules:
```powershell
netsh interface portproxy show all
```
If you see `0.0.0.0:8000` or `listenport=8000`, delete that rule:
```powershell
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000
```
If there is no portproxy rule, confirm which services are hosted by that PID:
```powershell
netstat -ano | findstr :8000
tasklist /svc /fi "PID eq 4700"
```
For temporary troubleshooting, you can stop IP Helper from Administrator PowerShell:
```powershell
Stop-Service iphlpsvc
```
This may affect networking, proxying, or forwarding features. Do not disable it long-term unless you know why it is safe. If the Windows forwarding rule must stay, use a different Planet backend port.
If the script prints `failed-stop-service` or `failed-stop-process`, the current shell does not have permission to clear the Windows listener. Startup stops immediately instead of launching the backend into the same port conflict.
### Which startup flags change default ports?
| Service | Default port | Flag |
| --- | --- | --- |
| Frontend | `3000` | `-f <port>` |
| Backend | `8000` | `-b <port>` |
| AI Provider | `8010` | `-a <port>` |
| Motion Agent | `8765` | `--motion-agent-port <port>` |
Example:
```bash
./planet.sh start -f 3001 -b 8001 -a 8101
```
## Windows / WSL / LAN
### LAN access does not work on Windows / WSL. What should I check?
Check in this order before changing firewall rules:
```bash
# In WSL or the shell running Planet
curl http://localhost:3000
curl http://localhost:8000/health
```
Then verify from Windows PowerShell:
```powershell
curl http://localhost:3000
curl http://localhost:8000/health
```
If both localhost checks pass but a phone or another computer cannot connect, start with LAN enabled:
```bash
./planet.sh start --allow-lan
```
Then 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
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
```
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?
`--allow-lan` binds the frontend, backend, and optional Motion Agent to `0.0.0.0`. If a remote browser needs to connect to the display machine's Motion Agent, pass the Agent URL explicitly:
```text
http://<LAN_IP>:3000/earth?motion=1&motionProvider=agent&motionAgent=ws://<LAN_IP>:8765/ws/gestures
```
Browser Camera mode does not need a `motionAgent` URL.
## Dependencies and Environment Variables
### Why should I use `uv` instead of `pip`?
Planet manages Python dependencies through `uv` and `pyproject.toml`. Avoid `pip install` in the project environment, because it can diverge from the lock file and startup scripts.
For Motion Agent live dependencies, use:
```bash
uv add mediapipe opencv-python
```
`planet.sh start --motion-agent` checks and installs those live dependencies automatically. To disable auto-install:
```bash
PLANET_MOTION_AGENT_AUTO_INSTALL=0 ./planet.sh start --motion-agent
```
### Why should I use `bun` instead of `npm run`?
The frontend runtime is Bun. This avoids WSL / Windows mixed-path issues that can happen when npm invokes `cmd.exe`.
Common commands:
```bash
bun install
bun run dev
bun run build
```
If a non-interactive shell cannot find `bun`, `planet.sh` searches the current PATH, `~/.bun/bin`, zsh config, and PowerShell command resolution.
### When does `planet.sh` read environment variables from `.zshrc`?
By default, `planet.sh` statically parses simple lines in `~/.zshrc`:
```bash
export KEY=value
KEY=value
```
This avoids slow shell themes, plugins, and interactive initialization. For complex shell expansion, opt in to source mode:
```bash
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
```
To ignore `~/.zshrc` while troubleshooting:
```bash
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
```
Never put real secret values in docs or commits; documentation should only mention variable names and purposes.
## Motion Capture / Cameras
### Does Browser Camera mode need the `motionAgent` parameter?
No. Browser Camera mode uses webpage `getUserMedia` and runs recognition locally in the browser.
Recommended URL:
```text
/earth?motion=1&motionProvider=browser
```
You can also open Earth settings, enable Motion Debug Mode, and select Browser Camera as the input source. The page must run on HTTPS or localhost, and the user must grant browser camera permission.
### When do I need Motion Agent?
Use Motion Agent for:
- dual USB cameras
- RTSP / HTTP camera streams
- edge devices or client integration
- a standalone local recognition service
Common commands:
```bash
./planet.sh start --motion-agent
./planet.sh start --motion-agent --motion-agent-camera-indexes 0,1
./planet.sh start --motion-agent --motion-agent-camera-urls rtsp://example/live
./planet.sh start --motion-agent --motion-agent-dry-run
```
`--motion-agent-dry-run` is only for protocol and frontend connection testing; it does not open cameras.
### Why does WSL not find my camera?
Windows cameras usually do not appear inside WSL as `/dev/video*`. Check first:
```bash
ls /dev/video*
```
If no device appears, use Browser Camera for ordinary web demos. For Agent live mode, use an RTSP / HTTP camera URL:
```bash
./planet.sh start --motion-agent --motion-agent-camera-urls http://192.168.1.20:8080/video
```
USB passthrough into WSL is an advanced path. The script does not silently downgrade missing-camera live mode to dry-run.
## Docker / AI Provider
### Why does changing the AI key, base URL, or model not rebuild the image?
Keys, base URLs, and model names are runtime configuration. They do not require a Docker image rebuild. Restart AI Provider:
```bash
./planet.sh restart -a
```
The first Docker build may be slow because of image layers or `uv sync` dependency downloads. Later builds reuse `.dockerignore`, BuildKit, and uv cache.
### What should I do when Docker health checks fail?
Start with:
```bash
./planet.sh health
```
Then inspect logs:
```bash
./planet.sh log
```
If only AI Provider is unhealthy, restart just that service:
```bash
./planet.sh restart -a
```
## Datasource and Collector Credentials
### Connectivity validation passes, but collection cannot read credentials. Why?
Connectivity validation can read saved console settings, environment variables, and some credentials from `~/.zshrc`. For actual collection, prefer saving credentials in Settings -> Collector Settings, especially for AISStream's long-lived WebSocket collector.
If `AISSTREAM_API_KEY` only lives in `~/.zshrc`, confirm the backend process actually inherited it. Otherwise validation may pass while the collector runtime has no key.
### Where should BarentsWatch / AISStream credentials live?
For temporary debugging, environment variables or `~/.zshrc` are fine:
```bash
export AISSTREAM_API_KEY="..."
export BARENTSWATCH_CLIENT_ID="..."
export BARENTSWATCH_CLIENT_SECRET="..."
```
For stable operation, save credentials in Collector Settings so connectivity validation, collection jobs, and Earth realtime aggregation use the same configuration.
## Docs / Permissions
### Why can I not see some Docs pages?
Docs visibility is controlled by Gatekeeper groups:
- Quickstart, Manual, FAQ, and other basic docs are public.
- Development docs usually require `docs_developer`.
- Operations and service-control docs usually require `docs_admin`.
- `admin` and `super_admin` have Docs access by default; ordinary users need groups assigned from the Users page.
## Earth Common Tasks
### Why did collecting a location candidate not write anything?
Collecting and saving are two separate actions. Candidates can be previewed on Earth first. A candidate is written only after clicking Save or using the unresolved list's one-click adopt flow.
Compute-center saves write to `compute_center_locations` and refresh the layer. Records with no candidate stay in the unresolved list; Planet does not fabricate a location from a country center or hard-coded hint.
### Why does Motion Debug not show camera video?
With the Browser Camera source, the debug panel shows the local browser camera preview and draws the skeleton over it. If `Skeleton Only` is enabled, the video preview is hidden and the panel keeps only the dark canvas plus red/green skeleton.
With the Motion Agent source, the Agent WebSocket sends normalized joints, bones, and matched gestures only. It does not stream raw camera frames to Earth, which keeps privacy risk, bandwidth, and latency lower. In that mode the panel is a skeleton debug view rather than a video stream.

View File

@@ -61,6 +61,9 @@ class LocationResolver(Protocol):
| `RegistryResolver` | `resolvers/registry.py` | Legacy generic resolver; current compute-center and BGP runtime paths do not use it to generate candidates |
| `NominatimResolver` | `resolvers/nominatim.py` | Runs a domain query plan against Nominatim with LRU cache and rate limiting |
| `InheritFromAnotherEntityResolver` | `resolvers/inherit.py` | Wraps an externally resolved entity location as a candidate |
| `LocationLLMFallback` | `location/llm_fallback.py` | Generates a confirmation-required candidate through the current default AI Provider when user-triggered collection has no regular candidates |
Nominatim is the geocoding service in the OpenStreetMap ecosystem. Given a place name, city, country, organization, or facility query, it returns possible coordinates, a display name, and structured address fields. It is useful for turning city/facility text into candidate coordinates, but it is not an authoritative fact registry and can match same-name places or broad administrative areas. Planet therefore treats Nominatim output as confirmation-required candidates and uses it with caching and rate limiting.
`RegistryResolver` remains available for future controlled import scenarios, but it should not be reconnected as a hard-coded hint source for compute centers or BGP. Matching common fields such as `operator` or `city` was the main reason multiple entities could collapse onto the same point.
@@ -81,7 +84,7 @@ StoredComputeCenterLocationResolver()
The main map startup path is source coordinates first, then the database-backed current-location table. The table is `compute_center_locations`, keyed by `(source, source_id)`, and stores manually accepted locations or true coordinates migrated from source records. `init_db()` only migrates source records that already contain real coordinates; it does not import old hard-coded hints and does not run ROR, Nominatim, or LLM geocoding during startup.
Candidate collection is intentionally separate from rendering. `collect_location_candidates()` builds ROR and Nominatim/OpenStreetMap queries from source fields, but it does not emit the current `compute_center_locations` row as a candidate. After a user accepts a candidate, the save endpoint upserts it into the dimension table; the next map refresh renders it through `StoredComputeCenterLocationResolver`.
Candidate collection is intentionally separate from rendering. `collect_location_candidates()` builds ROR and Nominatim/OpenStreetMap queries from source fields, but it does not emit the current `compute_center_locations` row as a candidate. If those regular candidates are empty, the API layer calls `LocationLLMFallback` through the current default AI Provider and only returns `source="llm_location_factcheck"` candidates with `needs_confirmation=true`. LLM candidates use a combined threshold made from the model self-score plus backend evidence scoring; when the LLM provides a credible city/country but no coordinates, the backend may fill city-level coordinates through Nominatim without increasing the evidence score. After a user accepts a candidate, the save endpoint upserts it into the dimension table; the next map refresh renders it through `StoredComputeCenterLocationResolver`.
`resolve_compute_center_location()`, `resolve_compute_center_location_full()`, and `collect_location_candidates()` remain the domain API. `visualization.py` consumes that API and no longer owns coordinate hints, country-centroid fallbacks, or Nominatim details.
@@ -102,7 +105,7 @@ StoredCollectorLocationResolver()
NominatimResolver(_bgp_collector_query_plan)
```
The 23 RIPE RIS collector coordinates moved from the old table into the `bgp_collector_locations` dimension table with `source=legacy_seed` and `needs_confirmation=true`. The legacy dictionary is still maintained from the DB-backed cache for compatibility; manual candidate collection uses stored site/city/country as context but does not emit stored rows as candidates.
The 23 RIPE RIS collector coordinates moved from the old table into the `bgp_collector_locations` dimension table with `source=legacy_seed` and `needs_confirmation=true`. The legacy dictionary is still maintained from the DB-backed cache for compatibility; manual candidate collection uses stored site/city/country as context but does not emit stored rows as candidates. If Nominatim cannot produce a city-level candidate, the collection endpoint uses the current default AI Provider as an LLM factcheck fallback and returns a confirmation-required candidate instead of saving automatically.
### BGP Events
@@ -139,6 +142,26 @@ Both `collect-location` endpoints return the same envelope:
}
```
The LLM fallback only runs inside user-triggered `collect-location` requests, and only after regular candidates are empty. It does not run during `/geo/compute-centers` startup rendering, scheduled collection, or batch persistence, and it never writes directly to `compute_center_locations` or `bgp_collector_locations`. Internally it is no longer a single "strict JSON or fail" step. It first asks the LLM to factcheck the location; if the answer is not JSON, it makes a second normalization request that may only extract facts from the original text; if that still fails, it conservatively extracts a city/country pair from the prose. The backend then performs coordinate filling, combined scoring, and candidate creation through one shared path.
This lets an answer such as "DeepL Mercury is in Falun, Sweden" become a city-level candidate after backend Nominatim coordinate filling, and lets a prose first answer be normalized into JSON on the second pass. Regardless of the path, only `precise`, `site`, or `city` precision with non-zero coordinates and a sufficient combined score is converted to a candidate. Failed, low-score, country-only, or cityless responses stay as diagnostics.
The LLM-provided `confidence` is only the model's self-score. The backend recomputes a combined score and uses that value as the candidate `confidence`:
```text
combined =
0.25 * model_confidence
+ source_quality
+ entity_match
+ geography_match
+ precision_quality
+ name_location_hint
- conflict_penalty
- weak_evidence_penalty
```
Current component caps: authoritative/government/academic evidence can add up to `0.35`, reputable databases or news up to `0.25`, generic web evidence up to `0.15`; evidence that clearly names the queried entity can add `0.25`; city+country geography match adds `0.20`, country-only match adds `0.05`; precision adds `precise=0.15`, `site=0.12`, or `city=0.08`; `name_location_hint` adds signal when the entity name and candidate city overlap, such as `TAIPEI-1` and `Taipei`; explicit conflicts can subtract up to `0.45`; weak-evidence wording can subtract up to `0.30`, capped at `0.15` when entity and city/country match and no conflict is present. Candidates below `0.55` are rejected. This lets cases such as Alem.Cloud and TAIPEI-1 recover from a low model self-score when entity and city evidence align, while genuinely weak or conflicting evidence still fails.
`POST /api/v1/visualization/compute-centers/{source_id}/location` upserts the candidate selected by the frontend into `compute_center_locations`. Manual saves default to `needs_confirmation=false`, `verification_status="verified"`, and a `verified_at` timestamp. Future automated staging can pass `needs_confirmation=true` explicitly.
The frontend [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) renders the shared candidate list and preview events. The compute-center layer button shows an `unresolved` badge; clicking it opens the unresolved queue. Row-level `采集` only fetches candidates. Header-level `一键采用` walks the queue top-to-bottom, picks the highest-confidence candidate with valid coordinates, saves it, removes the row, renumbers the list, and dispatches `earth:compute-center-unresolved-count-change` so the badge updates immediately. When the batch finishes, `earth:compute-center-location-saved` refreshes the real layer.

View File

@@ -1,6 +1,6 @@
# Earth Location Candidate Collection User Guide
Location candidate collection helps fill or verify coordinates for compute centers and BGP collectors on Earth. Users do not type coordinates by hand; the backend ranks source coordinates, open organization-registry results, and online geocoding results into a previewable candidate list.
Location candidate collection helps fill or verify coordinates for compute centers and BGP collectors on Earth. Users do not type coordinates by hand; the backend ranks source coordinates, open organization-registry results, online geocoding results, and, when needed, LLM factcheck fallback results into a previewable candidate list.
## Supported Entities
@@ -18,13 +18,15 @@ Clicking a compute center or BGP collector on Earth opens a detail card with loc
| Field | Meaning |
| --- | --- |
| Location precision | Precise coordinates, site-level, city-level, or unconfirmed |
| Location source | Source coordinates, ROR organization registry, Nominatim online search, or stored BGP collector locations |
| Location source | Source coordinates, ROR organization registry, Nominatim online search, LLM factcheck fallback, or stored BGP collector locations |
| Location confidence | Relative confidence reported by the backend resolver |
| Verification status | Confirmed, estimated, or online result pending confirmation |
| Resolution reason | Why the location was selected |
| Matched location name | Canonical name from an open source, online result, or stored collector location |
| Verified at | Verification date for confirmed locations; online candidates are usually empty |
Nominatim here means the online geocoding service from the OpenStreetMap ecosystem. It converts place names, cities, countries, organizations, or campus/facility queries into possible coordinate candidates, but it can match same-name places or broad administrative areas. The UI therefore treats these results as pending confirmation.
Compute-center GeoJSON no longer renders country centroids, unknown locations, or `[0, 0]` placeholders. Records that cannot reach city-level precision are returned in the endpoint's `unresolved` list and can be improved through candidate collection.
A compute center with a `?` marker on Earth is not unresolved. It already has coordinates, but the coordinates still need confirmation, either because `needs_confirmation=true` or because the source is online geocoding. Truly unresolved records have no trustworthy coordinates and are therefore absent from the globe.
@@ -82,7 +84,7 @@ Both `collect-location` endpoints use the same response shape:
}
```
When no candidate reaches city-level precision, `success` is `false` and the response includes `failure_reason` plus the attempted queries. This helps distinguish missing source fields, open-source gaps, and online geocoding misses.
When regular candidates are empty, the endpoint asks the current default AI Provider for one LLM factcheck fallback. LLM candidates always require human confirmation and are never saved automatically; only strict JSON results with city-or-better precision, non-zero coordinates, and sufficient confidence appear in the candidate list. When no candidate reaches city-level precision, `success` is `false` and the response includes `failure_reason`, `llm_failure_reason`, and attempted queries. This helps distinguish missing source fields, open-source gaps, online geocoding misses, and unusable LLM responses.
## Registry Maintenance
@@ -122,6 +124,10 @@ Earth only renders coordinates that reach city-level precision or better. If sou
Nominatim/OpenStreetMap results may match same-name cities, organizations, or campuses. They are useful for previewing candidates, but should be manually confirmed before being persisted as verified locations.
### Can the LLM fallback change the map directly?
No. The LLM runs only after a user clicks candidate collection and regular sources have no candidates. It returns confirmation-required candidates only. Earth startup GeoJSON, scheduled collection, and batch rendering do not call the LLM automatically; a location affects future rendering only after a user saves the candidate into the dimension table.
### Why do BGP events no longer all land in Amsterdam?
The old behavior could match common fields like `operator="RIPE NCC"` and incorrectly promote `rrc00`. BGP event inheritance now uses a strict owning-collector lookup in the DB-backed cache instead of registry fuzzy matching.

View File

@@ -7,7 +7,7 @@ This manual is for daily use, demos, development integration, and local operatio
- Console: admin backend (login required)
- Docs: backend Gatekeeper-controlled documentation; basic usage docs are public, while developer and operations docs require permission groups
For the shortest path to getting started, see [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md).
For the shortest path to getting started, see [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md). For common troubleshooting, see the [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md).
## Entry Overview
@@ -17,6 +17,7 @@ After a default startup, the common URLs are:
| --- | --- | --- | --- |
| Earth | `http://localhost:3000/earth` | No | 3D globe, layers, BGP, satellites, cables, news situational awareness |
| 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 |
| Backend API Docs | `http://localhost:8000/docs` | Depends on endpoint | FastAPI / OpenAPI documentation |
@@ -280,7 +281,7 @@ Search results can be used to quickly locate objects and open their details.
### Location Candidate Collection
Compute-center and BGP collector detail cards can collect candidate coordinates automatically. After clicking an object, use `自动采集坐标候选` or `重新自动采集坐标`; the backend ranks source coordinates, open organization lookups, and Nominatim online search results. Stored BGP collector locations are used as query context only and are not emitted as candidates.
Compute-center and BGP collector detail cards can collect candidate coordinates automatically. After clicking an object, use `自动采集坐标候选` or `重新自动采集坐标`; the backend ranks source coordinates, open organization lookups, and Nominatim online search results. If those regular sources return no candidates, the current default AI Provider is used once as an LLM factcheck fallback. Stored BGP collector locations are used as query context only and are not emitted as candidates.
Candidates can be previewed directly on Earth. Compute-center candidates can be saved into the `compute_center_locations` dimension table from the detail card, then the layer refreshes immediately. The notification badge on the compute-center layer row shows unresolved records that cannot be rendered; clicking it opens the queue, where users can collect individual candidates or use `一键采用` to save the highest-confidence candidate top-to-bottom. Records without candidates stay in the queue and are not replaced by country centroids or hard-coded hints. See [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md) for the full workflow.
@@ -288,11 +289,10 @@ Candidates can be previewed directly on Earth. Compute-center candidates can be
The settings panel contains:
- Rotation mode / cruise mode
- Cruise modules: BGP, News
- Satellite display style: self-glow, real ground footprint
- Day/night mode
- Panel visibility toggles
- Rotation mode / cruise mode / motion mode
- Cruise modules: BGP, News, Compute Centers, Vessels, Cables, Satellites
- View settings: satellite display style, day/night mode, panel visibility
- Motion Debug Mode, Motion Input Source, skeleton-only debug view
- Globe default size
- Terrain opacity
- Reset settings
@@ -318,6 +318,30 @@ When zooming, the top capsule briefly shows the current zoom level, for example
Drag sensitivity adjusts automatically based on the current zoom. Around the default view it keeps the normal rotation feel; when zoomed in, dragging becomes progressively finer for inspecting a region, vessel, satellite, or BGP event; when zoomed out, dragging is slightly faster for global browsing.
### Motion Capture Controls
Earth has a motion-capture control entry point for large-screen and future 3D displays. There are two realtime input sources: the default `Browser Camera` source uses webpage `getUserMedia` and recognizes gestures locally in the browser; the advanced `Motion Agent` source uses `camera/RTSP/HTTP -> local Agent -> local WebSocket -> Earth page`. Neither path sends camera frames or realtime gesture decisions to the cloud, and neither path reuses the news/RSS aggregation APIs.
It is disabled by default. Enable `Motion Debug Mode` in settings, open Earth with `?motion=1`, or set `planet-earth-motion-control-enabled=true` in browser local storage to start the selected source. The default source is `Browser Camera`; it requires HTTPS or localhost and a granted browser camera permission, but does not require installing an app. For dual cameras, USB indexes, phone/network camera streams, client integration, or edge devices, switch the setting to `Motion Agent`. The default Agent URL is `ws://127.0.0.1:8765/ws/gestures`; the `motionAgent` URL parameter can override it.
URL parameters can also force the source: `?motion=1&motionProvider=browser` uses the browser camera, `?motion=1&motionProvider=agent` uses Motion Agent, and providing `motionAgent=ws://...` automatically selects Motion Agent.
Current gesture semantics:
| Gesture event | Result |
| --- | --- |
| `rotate_left` | Rotates the globe left |
| `rotate_right` | Rotates the globe right |
| `rotate_up` | Rotates the globe upward |
| `rotate_down` | Rotates the globe downward |
| `zoom_in` | Zooms in |
| `zoom_out` | Zooms out |
| `focus_prev` / `focus_next` | Switches targets within the current motion layer |
| `layer_prev` / `layer_next` | Switches the motion candidate layer and cruises to the nearest target in that layer |
| `confirm` | Confirms the currently selected target; browser recognition currently keeps the two-hands-up confirm gesture disabled |
The settings panel also includes `Motion Debug Mode`, which opens the debug panel. With the Browser Camera source, the panel shows a local live preview and draws joints and bones over it. With the Motion Agent source, the Agent sends normalized skeleton events only and does not send raw video frames. The `Skeleton Only` switch hides the video preview and keeps the dark canvas plus skeleton; `Stop Matching Gestures` pauses gesture execution while preview and skeleton drawing can continue for debugging. Unmatched skeletons are red; once a gesture matches, the skeleton turns green and the matched gesture name is shown. Both this entry and the `Motion Input Source` control already carry Gatekeeper permission markers for future authorization control.
### Cruise Mode
Cruise mode makes Earth automatically cycle through focus targets.
@@ -326,6 +350,10 @@ Current cruise modules:
- BGP
- News
- Compute Centers
- Vessels
- Cables
- Satellites
Suitable for demos, monitoring displays, or unattended presentations.

View File

@@ -177,7 +177,7 @@ Frontend startup now has an additional pre-start cleanup retry layer:
- `PORT_PRESTART_RETRIES`: defaults to 3 attempts.
- `PORT_PRESTART_RETRY_INTERVAL`: defaults to 2 seconds.
`kill_port_if_requested()` only kills processes when the current environment can identify listening PIDs. If no PID is visible but the port still cannot bind, it logs diagnostics and lets the service startup flow make the final decision. `start_frontend_with_retry()` only enters the pre-cleanup retry path when a listener PID is visible, so the script no longer spends its retry budget repeatedly killing nothing while a host-side or external network namespace is still releasing the port. Seeing "no listener found but port still unavailable" on the first restart usually means the external environment is still releasing the port, not that a local process cleanup loop is useful.
`kill_port_if_requested()` first cleans listener PIDs visible in the current environment. It only checks for Windows-side listeners when the script detects WSL, no local listener PID is visible, and the port still cannot bind. In that WSL-only path it tries to stop the owning Windows service or force-stop the owning process through PowerShell. If permissions are missing, or a system service such as `iphlpsvc` refuses to stop, the script prints the Windows listener details and stops startup immediately instead of launching the service into the same port error. Non-WSL environments do not run the Windows cleanup path. At that point, use Administrator PowerShell to clear the portproxy/service ownership, or choose another port.
## Issue 4: `restart` Behavior
@@ -188,6 +188,92 @@ Before the stamp path fix:
After moving the stamp file, plain `restart` uses the same `stop + start` behavior and the same fingerprint check as `restart -b`.
## Optional Motion Agent Startup
`planet.sh` can now manage the local Motion Capture Agent. It is disabled by default so ordinary development machines do not fail startup when cameras, OpenCV, or MediaPipe are unavailable.
Start it with:
```bash
./planet.sh start --motion-agent
```
Common options:
- `--motion-agent` / `-m`: start or restart the Motion Agent for this command.
- `--motion-agent-port <port>`: override the default WebSocket port `8765`.
- `--motion-agent-camera-indexes <indexes>`: override auto-detected camera indexes, for example `0` or `0,1`. The same can be provided through `MOTION_AGENT_CAMERA_INDEXES=0,1`.
- `--motion-agent-camera-urls <urls>`: use RTSP/HTTP camera streams, useful for WSL, phone cameras, or network cameras. The same can be provided through `MOTION_AGENT_CAMERA_URLS=...`.
- `--motion-agent-dry-run`: start only the protocol service without opening cameras or loading CV dependencies; useful for Web client debugging.
Non-dry-run live mode checks `mediapipe` and `opencv-python` before startup. If the current `.venv` is missing them, the script automatically runs:
```bash
uv add mediapipe opencv-python
```
To disable startup-time auto-install:
```bash
PLANET_MOTION_AGENT_AUTO_INSTALL=0 ./planet.sh start --motion-agent
```
Live mode auto-detects `/dev/video*` and passes the first two indexes to the Motion Agent. In WSL, Windows cameras usually do not appear as `/dev/video*` automatically. Check available devices first:
```bash
ls /dev/video*
```
To override auto-detection, pass indexes explicitly:
```bash
./planet.sh start --motion-agent --motion-agent-camera-indexes 1,2
```
In WSL, the more general path is to connect a phone or network camera through an RTSP/HTTP stream:
```bash
./planet.sh start --motion-agent --motion-agent-camera-urls http://192.168.1.20:8080/video
```
If WSL has no `/dev/video*` and no `--motion-agent-camera-urls` is provided, live startup stops and prints guidance instead of silently falling back to dry-run. Choose one of:
```bash
./planet.sh start --motion-agent --motion-agent-camera-urls http://<phone-ip>:8080/video
./planet.sh start --motion-agent --motion-agent-dry-run
```
Automatic dry-run fallback only happens when `PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1` is explicitly set.
Environment-variable startup is also supported:
```bash
PLANET_START_MOTION_AGENT=1 ./planet.sh start
MOTION_AGENT_DRY_RUN=1 PLANET_START_MOTION_AGENT=1 ./planet.sh start
```
Logs:
```bash
./planet.sh log -m
```
To expose it together with the frontend on the LAN:
```bash
./planet.sh start --allow-lan --motion-agent
```
In this mode the Motion Agent binds `0.0.0.0`, and startup output prints both the local WebSocket URL and the recommended LAN WebSocket URL. When opening Earth from another LAN browser, point `motionAgent` at the display machine:
```text
http://<LAN_IP>:3000/earth?motion=1&motionAgent=ws://<LAN_IP>:8765/ws/gestures
```
`./planet.sh stop` also stops a script-managed Motion Agent. `./planet.sh health` reports its online/offline status. The Earth page still requires `?motion=1` or browser local storage to enable the Web client connection explicitly.
For ordinary web, WSL, or no-install demo scenarios, you can skip Motion Agent entirely: choose the `Browser Camera` input source in Earth settings and enable Motion Debug Mode. This route uses browser `getUserMedia`, so the page must run on HTTPS or localhost and the user must grant camera permission.
## Other Cleanup
Two redundant `sleep 3` waits were removed because health checks already cover the same readiness:

View File

@@ -2,6 +2,8 @@
This guide is for developers or demo operators starting Planet for the first time. The goal is to get services running via the shortest path and know which URLs to open.
If you run into port conflicts, Windows / WSL LAN access, `uv` / `bun`, camera, or Docs permission issues, start with the [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md).
## Prerequisites
Recommended: run in a WSL / Linux shell.
@@ -64,6 +66,8 @@ If the default ports are taken, specify custom ports:
./planet.sh start -f 3001 -b 8001 -a 8101
```
If backend port `8000` is occupied by a Windows listener or an old portproxy rule, follow the [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md) troubleshooting order.
## 2. Create a Login User
The console requires login. For first-time use:
@@ -91,9 +95,9 @@ Once in, verify:
- The globe renders correctly
- The right-side layer panel can toggle layers on/off
- Search can find cables, satellites, compute centers, BGP events
- Compute-center and BGP collector detail cards can collect and preview coordinate candidates; the compute-center unresolved badge can open the queue and save candidates
- Compute-center and BGP collector detail cards can collect and preview coordinate candidates; when regular sources have no candidate, the current default AI Provider runs one LLM factcheck fallback; the compute-center unresolved badge can open the queue and save candidates
- Mouse drag, wheel zoom, and zoom percent feedback work correctly
- Settings panel can switch cruise mode, day/night mode, satellite display style
- Settings panel can switch rotate / cruise / motion mode, day/night mode, and satellite display style; Motion Debug Mode can show the local Browser Camera preview plus skeleton overlay
## 4. Open the Console

View File

@@ -23,6 +23,7 @@
- [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md):从零启动 Planet 的最短路径
- [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md):控制台、`planet.sh`、Earth 和 Docs 的完整使用手册
- [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md):Windows / WSL、端口、依赖、动捕、凭证和 Docs 权限的集中排障入口
- [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md):在 Earth 上为算力中心和 BGP 观测站采集、预览坐标候选
- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md):数据源目录、采集器设置、连接验证、BarentsWatch 凭证链路
- [通用位置估算管线开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-development.md):后端 location resolver / pipeline 的接口、注册表和扩展方式

View File

@@ -23,11 +23,12 @@
- 业务层请求整理
- 稳定的 `/api/v1/ai/...` 接口
- 面向 `aiprovider` 的内部服务认证
- 读取配置中心保存的默认 provider、模型和每个 provider 的 key,并通过内部请求头覆盖 `aiprovider` 的 `.env` 默认值
`aiprovider` 负责:
- 模型协议适配
- 基于 `.env` 选择 provider
- 在没有后端覆盖头时基于 `.env` 选择 provider
- 超时和轻量重试
- 通过 `X-Request-ID` 串联请求追踪
@@ -85,6 +86,18 @@
后端会把 `X-Request-ID` 透传给 `aiprovider`,并在响应中返回同一个 header。
### 设置中心 API
AI 配置页使用的接口:
- `GET /api/v1/settings/integrations`
- `PUT /api/v1/settings/integrations`
- `POST /api/v1/settings/integrations/ai-provider/connect`
- `GET /api/v1/settings/integrations/ai-provider/secrets`
- `GET /api/v1/settings/integrations/ai-provider/presets`
这些接口都需要用户登录。`secrets` 接口只用于配置页点击显示 key/token 时取回明文,隐藏时前端恢复为脱敏预览。
### AI Provider 内部 API
仅供内部调用的接口:
@@ -172,6 +185,75 @@ curl -X POST http://localhost:8010/v1/analyze \
## 配置
### 运行时配置链路
LLM 的全局默认配置由后端配置中心统一决定。实际调用顺序是:
1. 前端或业务代码调用 `backend` 的 `/api/v1/ai/...`。
2. `backend` 从 PostgreSQL 的 `system_settings` 表读取 `category = external_integrations`。
3. `payload.ai_provider.default_provider` 决定当前默认 provider。
4. `payload.ai_provider.providers[provider]` 提供该 provider 的 `api_key`、`provider_api`、`base_url`、`model`、`max_tokens`、`anthropic_version`。
5. `backend` 把这些值转换成 `X-AI-Provider`、`X-AI-Provider-API`、`X-AI-Base-URL`、`X-AI-API-Key`、`X-AI-Model` 等内部请求头。
6. `aiprovider` 收到头后用这些值覆盖自己的 `.env`,再调用真实模型厂商。
因此,只要 AI 设置页保存了新的默认 provider/model/key,Playground、告警摘要、数据源映射生成等所有后端 AI 调用都会使用同一个新默认配置。
#### 持久化结构
AI 配置仍保存在 PostgreSQL,不写入 JSON 文件。核心结构如下:
```json
{
"ai_provider": {
"service_url": "http://localhost:8010",
"service_token": "",
"default_provider": "openai",
"providers": {
"openai": {
"provider_api": "openai-completions",
"base_url": "https://api.openai.com/v1",
"model": "gpt-5.1",
"api_key": "<saved secret>",
"max_tokens": 4096,
"anthropic_version": "2023-06-01"
},
"minimax": {
"provider_api": "anthropic-messages",
"base_url": "https://api.minimaxi.com/anthropic",
"model": "MiniMax-M2.7",
"api_key": "<saved secret>",
"max_tokens": 1200,
"anthropic_version": "2023-06-01"
}
},
"timeout_seconds": 60,
"retry_attempts": 2
}
}
```
历史单槽配置会在读取时兼容映射到当前 provider 的 `providers[provider]`,保存后写回新结构。
#### Key fallback
每个 provider 都有自己的 key 槽。解析顺序是:
1. PostgreSQL 中 `providers[provider].api_key`
2. `aiprovider/.env` 中 preset 对应的专属变量,例如 `OPENAI_API_KEY`、`MINIMAX_API_KEY`、`ANTHROPIC_API_KEY`
3. `aiprovider/.env` 中的通用 `AI_API_KEY`
`.env` 只是兜底。配置页保存或测试连接成功后,PostgreSQL 中的配置会成为全局默认。
#### 配置页行为
- Provider 下拉框决定当前默认 provider。
- 模型下拉框保存当前 provider 的默认模型。
- LLM API Key 输入框隐藏时显示脱敏预览;有 `-` 前缀的 key 会保留前缀,例如 `sk-********`,没有前缀的 key 全量脱敏。
- 点击眼睛会从后端取回完整明文;再次隐藏会恢复脱敏预览。
- “保存 AI 配置”直接保存当前表单为全局默认配置。
- “测试连接”先用当前表单发起真实模型链路测试,成功后也会保存为全局默认配置;失败不会覆盖旧配置。
- 清空输入框并保存表示保留旧 key,不表示删除 key。
### 后端
推荐的后端 `.env`:
@@ -208,6 +290,18 @@ AI_HTTP_RETRY_ATTEMPTS=2
AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。
```
可选 provider 专属 key:
```env
MINIMAX_API_KEY=sk-cp-xxxxx
OPENAI_API_KEY=sk-xxxxx
ANTHROPIC_API_KEY=sk-ant-xxxxx
DEEPSEEK_API_KEY=sk-xxxxx
DASHSCOPE_API_KEY=sk-xxxxx
MOONSHOT_API_KEY=sk-xxxxx
OPENROUTER_API_KEY=sk-or-xxxxx
```
### OpenAI 兼容示例
```env

View File

@@ -88,7 +88,26 @@ React 路由入口:
手势提示不会抢占 loading 状态。对应样式是 [hud.css](/home/ray/dev/linkong/planet/frontend/public/earth/css/hud.css) 中的 `.earth-status-message.gesture`。
### 5. 地球与地形
### 5. 动作捕捉控制适配层
- [motion-control.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-control.js)
职责:
- 作为 Motion Provider manager,统一接入 `browser_camera` 与 `motion_agent`。
- 默认使用浏览器 `getUserMedia` + 本地 MediaPipe 识别;高级模式可连接本地 Motion Capture Agent WebSocket。
- 处理浏览器摄像头权限/安全上下文错误,以及 Agent 断线重连和 `status` / `heartbeat`。
- 过滤低置信度和过快重复的手势事件。
- 将 `rotate_left`、`rotate_right`、`rotate_up`、`rotate_down`、`zoom_in`、`zoom_out`、`focus_prev`、`focus_next`、`layer_prev`、`layer_next`、`confirm` 映射到 `main.js` 暴露的动作入口。
- 解析 `skeleton` 调试事件并派发 `earth:motion-debug-frame`。
动作捕捉识别可以在浏览器本地执行,也可以在本地 Agent 中执行,但两者都不会把实时视频帧发给 SaaS 云端。`main.js` 暴露旋转、缩放、目标切换、图层切换和确认入口,并通过 `window.__planetEarth.motion` 提供调试入口。默认只有 URL 参数 `?motion=1`、本地存储 `planet-earth-motion-control-enabled=true`,或 Earth 设置中的“动捕调试模式”打开时才启动当前 provider。
动捕调试面板由 [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"`。
[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. 地球与地形
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
- [terrain.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/terrain.js)
@@ -99,7 +118,7 @@ React 路由入口:
- 真实地形 mesh
- terrain tile 拉取、解码、位移、着色
### 6. 图层模块
### 7. 图层模块
- [satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
- [cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
@@ -119,6 +138,8 @@ React 路由入口:
- 面板内容
- hover/lock/selection 语义
`tv.js` 管理 `media-panel` 里的直播 / 态势新闻 tab。toolbar 打开或切换 TV/新闻时,会通过 `earth:tv-visibility-change` 和 `earth:tv-tab-change` 回写 Earth 设置:面板可见性仍按 desktop/mobile viewport 存在 `views.<scope>.panelVisibility.media-panel`,当前 tab 存在 `shared.mediaPanelActiveTab`,因此刷新页面后能恢复用户上次打开的直播或新闻状态。`closeTransientMobileOverlays()` 这类临时收起会带 `persist:false`,不会覆盖用户偏好。
其中 Earth 启动加载链现在也拆成了两层:
- `controls.js`
@@ -307,6 +328,8 @@ AISStream 的 `PositionReport` 常带实时位置和 `MetaData.ShipName`,但
算力中心图层行左上角的通知气泡显示 GeoJSON `unresolved` 数量。这个数字表示“完全没有可信坐标、不能渲染到地球上”的记录,不等同于地图上带 `?` 的已定位待确认点。点击气泡会在图层面板右侧打开固定信息卡,信息卡内容区内部滚动,不随鼠标 hover 消失。列表中的单条 `采集` 只展示候选;顶部 `一键采用` 会按当前列表顺序逐条采集、保存最高置信候选,成功一条就移除一条、重新编号,并通过 `earth:compute-center-unresolved-count-change` 同步气泡数量。批量结束后再触发 `earth:compute-center-location-saved` 刷新真实图层。
详情卡里的坐标候选状态由 [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) 按 `entityType:entityId` 缓存在模块内存中。用户关闭详情卡或待定位列表后再次打开同一个算力中心 / BGP 观测站,已经采集到的候选和状态文案会恢复;`一键采用` 会优先使用缓存候选,避免重复调用在线地理编码或 LLM factcheck。保存成功后该实体的候选列表会清空为“正在刷新图层”状态,避免旧候选在刷新后继续误导用户。
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 观测站按活跃度调整点大小。

308
docs/technical/zh/faq.md Normal file
View File

@@ -0,0 +1,308 @@
# 常见问题
这页集中收录本地启动、Windows / WSL、依赖、动捕、凭证和 Docs 权限相关的常见排障路径。更完整的背景说明仍在对应专题文档中,这里只保留最常用的判断顺序和命令。
## 启动与端口
### 启动时报后端地址已被占用怎么办?
现象通常类似:
```text
后端地址已被占用: 0.0.0.0:8000 / 127.0.0.1:8000 / [::1]:8000
Address already in use
```
先尝试:
```bash
./planet.sh restart -b
```
如果仍然占用,临时换端口:
```bash
./planet.sh start -b 8001
```
在 WSL 中,端口可能不是 Linux 进程占用,而是 Windows 侧 listener。常见输出如下:
```text
Windows listener: 0.0.0.0:8000 pid=4700 process=svchost.exe services=iphlpsvc
```
`iphlpsvc` 是 Windows IP Helper 服务。它经常承载 IPv6、隧道、代理、端口转发、WSL 或开发工具注册的网络能力。不要优先 `taskkill` 这个 `svchost.exe`;更推荐先找是不是旧的 portproxy 规则。
管理员 PowerShell 中先查 portproxy:
```powershell
netsh interface portproxy show all
```
如果看到 `0.0.0.0:8000` 或 `listenport=8000`,删除对应规则:
```powershell
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000
```
如果没有 portproxy 规则,再确认 PID 内承载的服务:
```powershell
netstat -ano | findstr :8000
tasklist /svc /fi "PID eq 4700"
```
临时排障可以在管理员 PowerShell 中停止 IP Helper:
```powershell
Stop-Service iphlpsvc
```
这可能影响部分网络、代理或转发能力。长期不推荐禁用该服务;如果必须保留 Windows 转发,改用不同后端端口更稳。
如果脚本输出 `failed-stop-service` 或 `failed-stop-process`,说明当前权限无法清理 Windows listener。脚本会停止启动,避免后端再次遇到同一端口冲突。
### 默认端口冲突时应该改哪些参数?
常用端口如下:
| 服务 | 默认端口 | 参数 |
| --- | --- | --- |
| 前端 | `3000` | `-f <port>` |
| 后端 | `8000` | `-b <port>` |
| AI Provider | `8010` | `-a <port>` |
| Motion Agent | `8765` | `--motion-agent-port <port>` |
示例:
```bash
./planet.sh start -f 3001 -b 8001 -a 8101
```
## Windows / WSL / 局域网
### Windows / WSL 下局域网访问不通怎么办?
建议按下面顺序排查:
```bash
# 在 WSL 或运行 Planet 的 shell 中
curl http://localhost:3000
curl http://localhost:8000/health
```
再到 Windows PowerShell 验证:
```powershell
curl http://localhost:3000
curl http://localhost:8000/health
```
如果 WSL 和 Windows localhost 都通,但手机或其他电脑访问不通,再考虑局域网开放:
```bash
./planet.sh start --allow-lan
```
管理员 PowerShell 中配置 portproxy 和防火墙:
```powershell
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
```
局域网设备访问的是 Windows 的局域网 IP,例如 `http://<Windows局域网IP>:3000/earth`,不是 WSL 内部 IP。
### `--allow-lan` 和 Motion Agent 局域网地址怎么配?
`--allow-lan` 会让前端、后端和可选 Motion Agent 监听 `0.0.0.0`。如果远端浏览器要连本机 Motion Agent,Earth URL 需要显式带 Agent 地址:
```text
http://<LAN_IP>:3000/earth?motion=1&motionProvider=agent&motionAgent=ws://<LAN_IP>:8765/ws/gestures
```
如果选择浏览器摄像头输入源,不需要 `motionAgent` 参数。
## 依赖与环境变量
### 为什么不要用 `pip`,要用 `uv`?
Planet 的 Python 依赖统一由 `uv` 和 `pyproject.toml` 管理。不要用 `pip install` 往当前环境里塞包,否则容易出现锁文件、虚拟环境和启动脚本不一致。
Motion Agent live 模式缺依赖时,推荐:
```bash
uv add mediapipe opencv-python
```
`planet.sh start --motion-agent` 会自动检查并安装这些 live 依赖。若要禁止自动安装:
```bash
PLANET_MOTION_AGENT_AUTO_INSTALL=0 ./planet.sh start --motion-agent
```
### 为什么不要用 `npm run`,要用 `bun`?
前端运行时统一使用 Bun,避免 WSL / Windows 混合环境里触发 `cmd.exe` 路径兼容问题。
常用命令:
```bash
bun install
bun run dev
bun run build
```
如果非交互 shell 找不到 `bun`,`planet.sh` 会依次查找当前 PATH、`~/.bun/bin`、zsh 配置和 PowerShell 中的可执行路径。
### `.zshrc` 里的环境变量什么时候会被读取?
`planet.sh` 默认只静态解析 `~/.zshrc` 中简单的:
```bash
export KEY=value
KEY=value
```
这样可以避免 shell 主题、插件或交互初始化拖慢启动。复杂 shell 展开需要显式启用 source 模式:
```bash
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
```
如果排障时想完全忽略 `~/.zshrc`:
```bash
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
```
不要把密钥值写进文档或提交到仓库;文档只应写变量名和用途。
## Motion Capture / 摄像头
### Browser Camera 模式需要 `motionAgent` 参数吗?
不需要。浏览器摄像头模式直接用网页 `getUserMedia` 调本机摄像头,并在浏览器本地识别动作。
推荐 URL:
```text
/earth?motion=1&motionProvider=browser
```
也可以在 Earth 设置中打开“动捕调试模式”,并把“动捕输入源”选为“浏览器摄像头”。页面必须运行在 HTTPS 或 localhost,且用户需要允许浏览器摄像头权限。
### Motion Agent 什么时候才需要?
这些场景才需要 Motion Agent:
- 双 USB 摄像头
- RTSP / HTTP 摄像头流
- 边缘设备或客户端集成
- 需要独立本地识别服务
常用命令:
```bash
./planet.sh start --motion-agent
./planet.sh start --motion-agent --motion-agent-camera-indexes 0,1
./planet.sh start --motion-agent --motion-agent-camera-urls rtsp://example/live
./planet.sh start --motion-agent --motion-agent-dry-run
```
`--motion-agent-dry-run` 只用于协议和前端连接测试,不会打开摄像头。
### WSL 下摄像头为什么扫不到?
Windows 摄像头通常不会自动出现在 WSL 的 `/dev/video*`。先确认:
```bash
ls /dev/video*
```
如果没有设备,普通网页演示优先走 Browser Camera。需要 Agent live 模式时,可以用 RTSP / HTTP 摄像头 URL:
```bash
./planet.sh start --motion-agent --motion-agent-camera-urls http://192.168.1.20:8080/video
```
USB 摄像头透传到 WSL 属于高级路径;脚本不会默认把无摄像头场景降级成 dry-run。
## Docker / AI Provider
### AI Provider 改了 key、Base URL 或模型后为什么没有重建镜像?
密钥、Base URL、模型这类运行期配置变化不会触发 Docker 镜像重建。重启 AI Provider 即可:
```bash
./planet.sh restart -a
```
首次构建慢通常是 Docker build context、镜像层或 `uv sync` 下载依赖耗时。后续构建会复用 `.dockerignore`、BuildKit 和 uv cache。
### Docker 健康检查没过怎么办?
先看统一健康检查:
```bash
./planet.sh health
```
再看日志:
```bash
./planet.sh log
```
如果只有 AI Provider 异常,优先重启单个服务:
```bash
./planet.sh restart -a
```
## 数据源与采集器凭证
### 采集器连接验证通过,但正式采集拿不到凭证怎么办?
连接验证会读取控制台保存配置、环境变量和部分 `~/.zshrc` 凭证。正式采集更推荐把凭证保存到“设置 -> 采集器设置”,尤其是 AISStream 这类长连接 collector。
如果只把 `AISSTREAM_API_KEY` 放在 `~/.zshrc`,需要确认后端进程实际继承了该变量。否则可能出现连接验证可用,但 collector 运行时没有 key 的情况。
### BarentsWatch / AISStream 凭证应该放哪里?
临时联调可以先放环境变量或 `~/.zshrc`,例如:
```bash
export AISSTREAM_API_KEY="..."
export BARENTSWATCH_CLIENT_ID="..."
export BARENTSWATCH_CLIENT_SECRET="..."
```
稳定运行时,优先在控制台采集器设置中保存凭证,保证连接验证、采集任务和 Earth 实时聚合使用同一份配置。
## Docs / 权限
### 为什么 Docs 里有些文档看不到?
Docs 按 Gatekeeper 权限组控制可见性:
- 快速开始、使用手册、FAQ 等基础文档公开可见。
- 开发文档通常需要 `docs_developer`。
- 运维和服务控制文档通常需要 `docs_admin`。
- `admin` 和 `super_admin` 默认具备 Docs 权限;普通用户需要在控制台“用户管理”中分配权限组。
## Earth 常见操作
### Earth 位置候选采集后没有写入怎么办?
“采集候选”和“保存候选”是两步。候选可以先在 Earth 上预览,只有点击保存或使用待定位列表中的“一键采用”后,才会写入维表并刷新图层。
算力中心候选保存后会写入 `compute_center_locations`。没有可用候选的记录会保留在待定位列表中,系统不会用国家中心点或硬编码 hint 伪造位置。
### 动捕调试面板为什么看不到摄像头画面?
如果输入源是 Browser Camera,调试面板会显示浏览器本机摄像头实时预览,并在画面上绘制骨架。如果勾选了 `只显示骨骼`,视频预览会被隐藏,只显示深色背景和红/绿骨架。
如果输入源是 Motion Agent,Agent WebSocket 只发送归一化关节点、骨架连线和匹配动作,不发送原始摄像头帧,以降低隐私、带宽和延迟风险。因此远端 Agent 模式下看到的是骨架调试视图,而不是视频流。

View File

@@ -61,6 +61,9 @@ class LocationResolver(Protocol):
| `RegistryResolver` | `resolvers/registry.py` | 遗留通用 resolver;当前算力中心和 BGP 运行时链路不使用它生成候选 |
| `NominatimResolver` | `resolvers/nominatim.py` | 按领域 query plan 调 Nominatim,带 LRU 缓存和速率限制 |
| `InheritFromAnotherEntityResolver` | `resolvers/inherit.py` | 把外部实体的已解析位置包装为候选 |
| `LocationLLMFallback` | `location/llm_fallback.py` | 用户触发候选采集且常规候选为空时,通过当前默认 AI Provider 生成待确认候选 |
Nominatim 是 OpenStreetMap 生态里的地理编码服务:给它一个地点名称、城市、国家或机构查询文本,它会返回可能匹配的经纬度、展示名称和地址结构。它适合把“城市/机构/园区名称”转成候选坐标,但不是权威事实库,可能命中同名地点或过宽泛的行政区,所以本项目只把它作为待确认候选来源,并带缓存和速率限制使用。
`RegistryResolver` 仍保留给后续可能的受控导入场景,但它不应被重新接入算力中心或 BGP 作为“硬编码 hint”候选源。过去仅凭 `operator`、`city` 等通用字段匹配 registry 容易把多个实体落到同一个点,这是这次下线 registry 候选链路的主要原因。
@@ -81,7 +84,7 @@ StoredComputeCenterLocationResolver()
主地图启动链路只做“源坐标优先,其次数据库维表坐标”。数据库表为 `compute_center_locations`,唯一键是 `(source, source_id)`,用于保存人工确认或从源记录真实坐标迁入的位置。`init_db()` 只幂等迁入源记录里已有的真实经纬度,不迁入旧硬编码 hint,不在启动期批量调用 ROR、Nominatim 或 LLM。
手动候选采集链路和渲染链路分开。`collect_location_candidates()` 使用源字段构造 ROR 和 Nominatim/OpenStreetMap 查询,但不会把 `compute_center_locations` 当前坐标当候选返回。用户在前端确认某个候选后,通过保存接口写入维表;之后地图刷新时由 `StoredComputeCenterLocationResolver` 渲染。
手动候选采集链路和渲染链路分开。`collect_location_candidates()` 使用源字段构造 ROR 和 Nominatim/OpenStreetMap 查询,但不会把 `compute_center_locations` 当前坐标当候选返回。如果这些常规候选为空,API 层会调用 `LocationLLMFallback`,通过当前默认 AI Provider 进行位置 factcheck,并只返回 `source="llm_location_factcheck"`、`needs_confirmation=true` 的候选。LLM 候选使用“模型自评分 + 后端证据评分”的组合阈值;如果 LLM 只给出可信 city/country 而没有坐标,后端会用 Nominatim 补城市级坐标,但不会因此提高证据分。用户在前端确认某个候选后,通过保存接口写入维表;之后地图刷新时由 `StoredComputeCenterLocationResolver` 渲染。
`resolve_compute_center_location()`、`resolve_compute_center_location_full()` 和 `collect_location_candidates()` 保留为领域 API。`visualization.py` 只消费领域 API,不再持有坐标提示常量、国家质心兜底或 Nominatim 细节。
@@ -102,7 +105,7 @@ StoredCollectorLocationResolver()
NominatimResolver(_bgp_collector_query_plan)
```
23 个 RIPE RIS collector 坐标从旧表迁入 `bgp_collector_locations` 维表,默认 `source=legacy_seed`、`needs_confirmation=true`。旧字典仍由 DB-backed cache 维护,保证下游接口兼容;手动候选采集不会把这份维表坐标当作候选,只用它补齐 site/city/country 查询上下文。
23 个 RIPE RIS collector 坐标从旧表迁入 `bgp_collector_locations` 维表,默认 `source=legacy_seed`、`needs_confirmation=true`。旧字典仍由 DB-backed cache 维护,保证下游接口兼容;手动候选采集不会把这份维表坐标当作候选,只用它补齐 site/city/country 查询上下文。若 Nominatim 也无法产出城市级候选,采集接口会用当前默认 AI Provider 做 LLM factcheck 兜底,返回待确认候选而不是自动保存。
### BGP 事件
@@ -139,6 +142,26 @@ POST /api/v1/bgp/collectors/{collector_id}/collect-location
}
```
LLM 兜底只发生在用户触发的 `collect-location` 请求中,并且只在常规候选为空时运行。它不会在 `/geo/compute-centers` 启动渲染、定时采集或批量入库流程中自动调用,也不会直接写入 `compute_center_locations` 或 `bgp_collector_locations`。LLM 兜底内部不是“一次严格 JSON 成败”的单点链路,而是小型结构化管线:先请求 LLM 做位置 factcheck;若返回不是 JSON,再发起一次“只从原文抽取、不新增事实”的结构化修复;若修复仍失败,则只从原文中保守抽取 city/country。随后统一由后端补坐标、算综合分并决定是否生成候选。
这条链路允许 LLM 只给出“DeepL Mercury 位于 Falun, Sweden”这类城市级事实,由后端用 Nominatim 补城市坐标;也允许模型第一轮输出自然语言,第二轮再归一化成 JSON。无论哪条路径,只有 `precise`、`site`、`city` 精度、非零坐标和足够综合分的结果会被转换成候选;失败、低分、只有国家级信息或无法抽出城市的响应会保留为诊断信息。
LLM 返回的 `confidence` 只是模型自评,后端会重新计算综合分并把它作为候选 `confidence`:
```text
combined =
0.25 * model_confidence
+ source_quality
+ entity_match
+ geography_match
+ precision_quality
+ name_location_hint
- conflict_penalty
- weak_evidence_penalty
```
当前分项上限:权威/政府/高校来源最高 `0.35`,可信数据库/新闻最高 `0.25`,普通网页最高 `0.15`;证据明确命中实体名最高 `0.25`;城市+国家匹配 `0.20`,只有国家匹配 `0.05`;精度项 `precise=0.15`、`site=0.12`、`city=0.08`;实体名与候选城市互相命中时增加 `name_location_hint`,例如 `TAIPEI-1` 对 `Taipei`;明确冲突最多扣 `0.45`,普通弱证据措辞最多扣 `0.30`,在实体和城市国家都已命中且无冲突时弱证据扣分封顶 `0.15`。综合分低于 `0.55` 的候选会被拒绝。这样 Alem.Cloud、TAIPEI-1 这类“模型自评分偏低,但实体和城市证据一致”的结果可以被后端公式拉回到可确认候选;真正证据弱或有冲突的结果仍会被拒绝。
`POST /api/v1/visualization/compute-centers/{source_id}/location` 把前端选中的候选 upsert 到 `compute_center_locations`。人工保存默认 `needs_confirmation=false`、`verification_status="verified"` 并写入 `verified_at`;如果后续接入自动暂存,也可以显式传 `needs_confirmation=true`。
前端 [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) 使用通用候选列表和预览事件渲染对象详情卡。算力中心图层按钮左上角会显示 `unresolved` 数量;点击角标打开待定位列表。列表中的 `采集` 只拉候选,`一键采用` 会逐条调用候选采集接口,选择最高置信且有有效经纬度的候选保存。保存成功一条就从列表移除并重新编号,同时通过 `earth:compute-center-unresolved-count-change` 同步角标;批量结束后再触发 `earth:compute-center-location-saved` 刷新真实图层。

View File

@@ -1,6 +1,6 @@
# Earth 位置候选采集使用手册
位置候选采集用于给 Earth 上的算力中心和 BGP 观测站补齐或核验经纬度。它不会要求用户手工输入坐标,而是把源数据、开放组织注册 API 和在线地理编码结果整理成候选列表,供用户预览和后续认领。
位置候选采集用于给 Earth 上的算力中心和 BGP 观测站补齐或核验经纬度。它不会要求用户手工输入坐标,而是把源数据、开放组织注册 API、在线地理编码结果,以及必要时的 LLM factcheck 兜底结果整理成候选列表,供用户预览和后续认领。
## 适用对象
@@ -18,13 +18,15 @@ BGP 事件的位置默认继承所属 collector。事件本身暂不提供单独
| 字段 | 含义 |
| --- | --- |
| 位置精度 | `精确坐标`、`站点级位置`、`城市级位置` 或 `位置未确认` |
| 位置来源 | 源数据坐标、ROR 组织注册 API、Nominatim 在线搜索,或已存储的 BGP collector 维表位置 |
| 位置来源 | 源数据坐标、ROR 组织注册 API、Nominatim 在线搜索、LLM factcheck 兜底,或已存储的 BGP collector 维表位置 |
| 位置置信度 | 后端 resolver 给出的相对置信度百分比 |
| 核验状态 | 已确认、估算位置或在线检索结果待确认 |
| 解析依据 | 为什么选择这个位置,例如匹配了哪个站点或城市 |
| 匹配的位置名称 | 开放来源、在线结果或已存储 collector 位置中的规范名称 |
| 位置核验时间 | 已确认位置的核验日期,在线候选通常为空 |
这里的 Nominatim 指 OpenStreetMap 生态中的在线地理编码服务。它会把地点名称、城市、国家、机构或园区查询文本转换为可能的经纬度候选,但结果可能命中同名地点或过宽泛的行政区,因此界面会把这类结果标为待确认。
算力中心 GeoJSON 不再渲染国家质心、未知位置或 `[0, 0]` 占位点。无法达到城市级精度的数据会进入接口的 `unresolved` 列表,并在图层开关左上角显示待定位数量。点击这个通知气泡会打开待定位列表。
地图上带 `?` 的算力中心不是 `unresolved`。它们已经有坐标,只是 `needs_confirmation=true` 或来自在线地理编码,仍需人工核验。真正 `unresolved` 的记录没有可信经纬度,因此不会出现在地球上。
@@ -82,7 +84,7 @@ POST /api/v1/bgp/collectors/{collector_id}/collect-location
}
```
当没有候选达到城市级精度时,`success` 为 `false`,响应会包含 `failure_reason` 和已尝试的查询文本,便于判断是源数据字段不足、开放来源缺项,还是在线地理编码没有命中。
常规候选为空时,接口会通过当前默认 AI Provider 做一次 LLM factcheck 兜底。LLM 候选始终需要人工确认,不会自动保存;只有达到城市级或更高精度、非零坐标且置信度足够的 JSON 结果才会出现在候选列表中。当仍没有候选达到城市级精度时,`success` 为 `false`,响应会包含 `failure_reason`、`llm_failure_reason` 和已尝试的查询文本,便于判断是源数据字段不足、开放来源缺项、在线地理编码没有命中,还是 LLM 返回不可用。
## 数据维护建议
@@ -122,6 +124,10 @@ Earth 只渲染达到城市级或更高精度的坐标。源数据没有坐标
Nominatim/OpenStreetMap 结果来自在线地理编码,可能匹配到同名城市、机构或园区。它可以用于快速定位和预览,但在写入已验证位置前应人工确认。
### LLM 兜底会不会直接改地图?
不会。LLM 只在用户点击采集候选且常规来源没有候选时运行,并只返回待确认候选。Earth 首屏 GeoJSON、定时采集和批量渲染不会自动调用 LLM;只有用户保存候选后,位置才会进入维表并参与后续渲染。
### 为什么 BGP 事件没有全部落到 Amsterdam
旧逻辑中,事件可能因为 `operator="RIPE NCC"` 这种通用字段误匹配到 `rrc00`。当前 BGP 事件继承只按所属 collector 在 DB-backed cache 中严格查找,不再用 registry 模糊匹配。

View File

@@ -7,7 +7,7 @@
- 控制台:登录后的管理后台
- Docs:后端 Gatekeeper 受控的文档站,基础使用文档公开,开发/运维文档按权限组开放
快速启动路径见 [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md)。
快速启动路径见 [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md)。常见排障见 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。
## 入口总览
@@ -17,6 +17,7 @@
| --- | --- | --- | --- |
| Earth | `http://localhost:3000/earth` | 否 | 3D 地球、图层、BGP、卫星、海缆、新闻态势 |
| 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 状态和调试 |
| 后端 API 文档 | `http://localhost:8000/docs` | 视接口而定 | FastAPI / OpenAPI 文档 |
@@ -311,7 +312,7 @@ Earth 搜索支持查找当前地球对象,例如:
### 位置候选采集
算力中心和 BGP 观测站详情卡支持自动采集坐标候选。点击对象后,使用详情卡中的 `自动采集坐标候选` 或 `重新自动采集坐标` 按钮,后端会从源坐标、开放组织注册 API 和在线地理编码中整理候选位置。BGP 观测站的已存储位置只用于补齐查询上下文,不会作为候选直接返回。
算力中心和 BGP 观测站详情卡支持自动采集坐标候选。点击对象后,使用详情卡中的 `自动采集坐标候选` 或 `重新自动采集坐标` 按钮,后端会从源坐标、开放组织注册 API 和在线地理编码中整理候选位置;这些常规来源没有候选时,会使用当前默认 AI Provider 做一次 LLM factcheck 兜底。BGP 观测站的已存储位置只用于补齐查询上下文,不会作为候选直接返回。
候选可以直接在 Earth 上预览。算力中心候选点击 `保存` 后会写入 `compute_center_locations` 维表,并立即刷新图层。算力中心图层左上角的通知气泡显示无法渲染的待定位数量;点击后可查看列表,单条采集候选,或用 `一键采用` 从上到下保存最高置信候选。没有可用候选的记录会留在列表中,不会被国家中心点或硬编码 hint 伪造位置。详细流程见 [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md)。
@@ -319,11 +320,10 @@ Earth 搜索支持查找当前地球对象,例如:
设置面板包含:
- 旋转模式 / 巡航模式
- 巡航模块:BGP、新闻
- 卫星显示风格:自身发光、真实地表覆盖
- 日夜模式
- 面板显示开关
- 旋转模式 / 巡航模式 / 动捕模式
- 巡航模块:BGP、新闻、算力中心、船只、海缆、卫星
- 视图设置:卫星显示风格、日夜模式、面板显示开关
- 动捕调试模式、动捕输入源、只显示骨骼
- 地球默认大小
- 地形透明度
- 重置设置
@@ -349,6 +349,30 @@ Earth 支持鼠标、触控板和触屏操作。
拖动灵敏度会根据当前缩放自动调整。默认视角附近保持常规旋转速度;放大后拖动会逐步变细,适合检查某个区域、船只、卫星或 BGP 事件;缩小后拖动会略快,方便快速浏览全球态势。
### 动作捕捉控制
Earth 预留了动作捕捉控制入口,面向大屏和未来 3D 展示。实时链路有两种输入源:默认的 `浏览器摄像头` 会直接用网页 `getUserMedia` 在本机浏览器识别;高级的 `Motion Agent` 会走 `摄像头/RTSP/HTTP -> 本地 Agent -> 本地 WebSocket -> Earth 页面`。两种模式都不会把摄像头帧或实时手势发到云端,也不会复用新闻/RSS 聚合接口。
默认不自动启用。打开设置里的 `动捕调试模式`,或用 `?motion=1` 打开 Earth 动捕连接后,系统会启动当前选择的输入源。输入源默认是 `浏览器摄像头`,无需安装应用,但页面必须运行在 HTTPS 或 localhost,且用户需要允许浏览器摄像头权限。需要双摄、USB index、手机/网络摄像头流或客户端/边缘设备时,可在设置中切到 `Motion Agent`。默认 Agent 地址是 `ws://127.0.0.1:8765/ws/gestures`,也可用 URL 参数 `motionAgent` 覆盖。
URL 参数也可以直接指定输入源:`?motion=1&motionProvider=browser` 使用浏览器摄像头;`?motion=1&motionProvider=agent` 使用 Motion Agent;传入 `motionAgent=ws://...` 时会自动选择 Motion Agent。
当前手势语义:
| 手势事件 | 作用 |
| --- | --- |
| `rotate_left` | 地球向左旋转 |
| `rotate_right` | 地球向右旋转 |
| `rotate_up` | 地球向上旋转 |
| `rotate_down` | 地球向下旋转 |
| `zoom_in` | 放大视角 |
| `zoom_out` | 缩小视角 |
| `focus_prev` / `focus_next` | 在当前动捕图层内切换可交互目标 |
| `layer_prev` / `layer_next` | 切换动捕候选图层,并巡航到新图层最近目标 |
| `confirm` | 确认当前已选目标;当前浏览器识别暂未启用双手上举确认 |
设置面板中的 `动捕调试模式` 会打开调试面板。浏览器摄像头输入源会在面板内显示本机实时预览,并在其上绘制关节点和连线;`Motion Agent` 输入源只发送归一化骨架事件,不发送原始视频帧。面板里的 `只显示骨骼` 会隐藏视频预览、只保留深色背景和骨架;`停止匹配动作` 会暂停手势触发,但摄像头预览和骨架绘制仍可继续用于调试。未匹配动作时骨架为红色,匹配后变绿并显示当前动作名称。该入口和 `动捕输入源` 控件都已预留 Gatekeeper 权限标记,后续可接入鉴权控制。
### 巡航模式
巡航模式会让 Earth 自动轮播聚焦目标。
@@ -357,6 +381,10 @@ Earth 支持鼠标、触控板和触屏操作。
- BGP
- 新闻
- 算力中心
- 船只
- 海缆
- 卫星
适合演示、监控大屏或无人值守展示。

View File

@@ -155,7 +155,7 @@ wait_for_port_release() {
- `PORT_PRESTART_RETRIES`:默认 3 次。
- `PORT_PRESTART_RETRY_INTERVAL`:默认 2 秒。
`kill_port_if_requested()` 只在当前环境能找到监听 PID 时主动杀进程;如果没有 PID 但端口暂时不可绑定,它会记录诊断并把最终确认交给服务启动流程。`start_frontend_with_retry()` 也只在发现监听 PID 时进入预清理重试,避免在宿主机或外部 network namespace 尚未释放端口时做无意义的“空杀重试”。这意味着第一次重启时看到“未发现监听进程但端口仍不可绑定”通常是外部环境仍在释放端口;脚本不会再把这种情况当成立刻失败的本地进程清理问题。
`kill_port_if_requested()` 优先清理当前环境能找到的监听 PID;只有检测到当前运行在 WSL 且没有可杀 PID、但端口仍不可绑定时,才会检查 Windows 侧 listener,并尝试通过 PowerShell 停止对应服务或强制结束对应进程。若没有权限,或 `iphlpsvc` 这类系统服务拒绝停止,脚本会打印 Windows listener 详情并立即停止启动,不再继续拉起服务碰同一个端口错误。非 WSL 环境不会尝试 Windows 清理路径。此时需要用管理员 PowerShell 清理 portproxy/服务占用,或改用其他端口。
## 问题三:端口检测用 Python
@@ -202,6 +202,92 @@ PY
修复戳文件路径后,无参 `restart` 同样使用 `stop + start`,fingerprint 检查正常生效,行为与 `restart -b` 完全一致。无需额外代码变更。
## Motion Agent 可选启动
`planet.sh` 现在可以管理本地动作捕捉 Agent,但默认不会启动它,避免普通开发机因为没有摄像头、OpenCV 或 MediaPipe 而影响后端/前端启动。
启动方式:
```bash
./planet.sh start --motion-agent
```
常用参数:
- `--motion-agent` / `-m`:随本次启动或重启拉起 Motion Agent。
- `--motion-agent-port <端口>`:覆盖默认 WebSocket 端口 `8765`。
- `--motion-agent-camera-indexes <indexes>`:覆盖自动发现的摄像头 index,例如 `0` 或 `0,1`。也可以用环境变量 `MOTION_AGENT_CAMERA_INDEXES=0,1`。
- `--motion-agent-camera-urls <urls>`:使用 RTSP/HTTP 摄像头流,适合 WSL、手机摄像头或网络摄像头。也可以用环境变量 `MOTION_AGENT_CAMERA_URLS=...`。
- `--motion-agent-dry-run`:不打开摄像头、不加载 CV 依赖,只启动协议服务,适合调试 Web 端连接。
非 dry-run 的 live 模式会在启动前检查 `mediapipe` 和 `opencv-python`。如果当前 `.venv` 缺包,脚本会自动执行:
```bash
uv add mediapipe opencv-python
```
如需禁止启动时自动安装,可设置:
```bash
PLANET_MOTION_AGENT_AUTO_INSTALL=0 ./planet.sh start --motion-agent
```
live 模式会自动寻找 `/dev/video*`,优先取前两个 index 传给 Motion Agent。在 WSL 中,Windows 摄像头通常不会自动出现在 `/dev/video*`。可先用下面命令看设备:
```bash
ls /dev/video*
```
如需覆盖自动发现结果,可手动指定 index:
```bash
./planet.sh start --motion-agent --motion-agent-camera-indexes 1,2
```
WSL 下更通用的方式是把手机摄像头或网络摄像头以 RTSP/HTTP 流接入:
```bash
./planet.sh start --motion-agent --motion-agent-camera-urls http://192.168.1.20:8080/video
```
如果 WSL 中没有发现 `/dev/video*`,且没有提供 `--motion-agent-camera-urls`,脚本会停止 live 启动并提示处理方式,不会自动降级为 dry-run。可选处理:
```bash
./planet.sh start --motion-agent --motion-agent-camera-urls http://<手机IP>:8080/video
./planet.sh start --motion-agent --motion-agent-dry-run
```
只有显式设置 `PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1` 时,WSL 无摄像头才会自动降级。
也可以用环境变量启用:
```bash
PLANET_START_MOTION_AGENT=1 ./planet.sh start
MOTION_AGENT_DRY_RUN=1 PLANET_START_MOTION_AGENT=1 ./planet.sh start
```
日志入口:
```bash
./planet.sh log -m
```
和前端一起开放局域网时:
```bash
./planet.sh start --allow-lan --motion-agent
```
此时 Motion Agent 会绑定 `0.0.0.0`,启动输出会同时显示本机 WebSocket 地址和推荐局域网 WebSocket 地址。局域网浏览器访问 Earth 时,需要把 `motionAgent` 参数指向这台大屏主机,例如:
```text
http://<LAN_IP>:3000/earth?motion=1&motionAgent=ws://<LAN_IP>:8765/ws/gestures
```
停止时 `./planet.sh stop` 会一并停止已由脚本启动的 Motion Agent。健康检查会显示 `Motion Agent` 的 online/offline 状态。Earth 页面仍需用 `?motion=1` 或浏览器本地存储显式启用 Web 端连接。
如果只是普通网页/WSL/无安装演示场景,可以不启动 Motion Agent,直接在 Earth 设置里选择 `浏览器摄像头` 输入源并打开动捕调试模式;该路线使用浏览器 `getUserMedia`,需要 HTTPS 或 localhost 和摄像头权限。
## 其他:移除不必要的 sleep
启动链路中两处 `sleep 3` 在实际已有健康检查覆盖的情况下多余,已移除:

View File

@@ -2,6 +2,8 @@
这份快速开始面向第一次启动 Planet 的开发者或演示操作者。目标是用最短路径把服务跑起来,并知道应该打开哪些入口。
如果遇到端口占用、Windows / WSL 局域网访问、`uv` / `bun`、摄像头或 Docs 权限问题,先看 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。
## 前置条件
推荐在 WSL / Linux shell 中运行。
@@ -64,6 +66,8 @@ export BARENTSWATCH_CLIENT_SECRET="..."
./planet.sh start -f 3001 -b 8001 -a 8101
```
后端 `8000` 被 Windows listener 或旧 portproxy 占用时,排查顺序见 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。
## 2. 创建登录用户
控制台需要登录。首次使用可以执行:
@@ -91,9 +95,9 @@ Earth 是公开页面,不需要登录。
- 地球正常显示
- 右侧图层控制可打开/关闭图层
- 搜索可以查找海缆、卫星、算力中心、BGP 事件
- 算力中心和 BGP 观测站详情卡可以自动采集并预览坐标候选;算力中心待定位气泡可以打开列表并保存候选
- 算力中心和 BGP 观测站详情卡可以自动采集并预览坐标候选;常规来源无候选时会用当前默认 AI Provider 做 LLM factcheck 兜底;算力中心待定位气泡可以打开列表并保存候选
- 鼠标拖动、滚轮缩放和缩放百分比提示正常工作
- 设置面板可以切换巡航模式、日夜模式、卫星显示风格
- 设置面板可以切换旋转 / 巡航 / 动捕模式、日夜模式、卫星显示风格;动捕调试模式下浏览器摄像头可显示本机预览和骨架
## 4. 打开控制台

View File

@@ -16,12 +16,13 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.49.0`
- `dev` 当前开发分支历史推导到:`0.50.0`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `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 聚合 |
| `0.47.0` | feature | `dev` | `pending` | 新增 AISStream WebSocket 船只采集器、多源 AIS 原始观测聚合、采集器状态配置、船型显示修正和文档规则解耦 |

View File

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

View File

@@ -2502,6 +2502,176 @@ label.is-disabled.earth-mobile-settings-card {
pointer-events: none;
}
.hud-panel-motion-debug {
position: absolute;
right: calc(24px * var(--hud-scale));
top: calc(98px * var(--hud-scale));
width: min(calc(340px * var(--hud-scale)), calc(100vw - 32px));
padding: 0;
border-radius: 0;
z-index: 36;
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
}
.motion-debug-header {
min-height: calc(30px * var(--hud-scale));
padding: calc(8px * var(--hud-scale)) calc(10px * var(--hud-scale));
}
.motion-debug-header-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: calc(8px * var(--hud-scale));
min-width: 0;
}
.motion-debug-body {
display: flex;
flex-direction: column;
gap: var(--hud-gap-sm);
padding: calc(10px * var(--hud-scale));
}
.motion-debug-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--hud-gap-sm);
color: rgba(226, 232, 240, 0.88);
font-size: calc(0.72rem * var(--hud-scale));
line-height: 1.35;
}
.motion-debug-title {
display: inline-flex;
align-items: center;
gap: var(--hud-gap-xs);
min-width: 0;
}
.motion-debug-title .material-symbols-rounded {
flex: 0 0 auto;
font-size: calc(1.02rem * var(--hud-scale));
color: rgba(255, 77, 95, 0.9);
}
.hud-panel-motion-debug.is-motion-matched .motion-debug-title .material-symbols-rounded {
color: rgba(57, 229, 140, 0.95);
}
.motion-debug-status {
flex: 0 1 auto;
color: rgba(148, 163, 184, 0.9);
font-size: calc(0.68rem * var(--hud-scale));
line-height: 1.25;
text-align: right;
}
.motion-debug-canvas {
display: block;
width: 100%;
height: calc(194px * var(--hud-scale));
min-height: calc(170px * var(--hud-scale));
aspect-ratio: 16 / 11;
border-radius: 0;
border: 1px solid rgba(255, 77, 95, 0.22);
background: rgba(8, 12, 20, 0.82);
}
.hud-panel-motion-debug.is-motion-matched .motion-debug-canvas {
border-color: rgba(57, 229, 140, 0.32);
}
.motion-debug-footer strong {
color: #ff7180;
font-weight: 700;
}
.hud-panel-motion-debug.is-motion-matched .motion-debug-footer strong {
color: #39e58c;
}
.hud-panel-motion-debug.is-motion-recognition-paused .motion-debug-footer strong {
color: rgba(250, 204, 21, 0.94);
}
.motion-debug-toggle {
display: inline-flex;
align-items: center;
gap: calc(8px * var(--hud-scale));
width: max-content;
color: rgba(226, 232, 240, 0.82);
font-size: calc(0.7rem * var(--hud-scale));
line-height: 1.2;
cursor: pointer;
user-select: none;
}
.motion-debug-toggle input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.motion-debug-toggle-track {
position: relative;
width: calc(30px * var(--hud-scale));
height: calc(17px * var(--hud-scale));
flex: 0 0 auto;
border: 1px solid rgba(148, 163, 184, 0.34);
background: rgba(15, 23, 42, 0.66);
}
.motion-debug-toggle-track::after {
content: "";
position: absolute;
top: calc(2px * var(--hud-scale));
left: calc(2px * var(--hud-scale));
width: calc(11px * var(--hud-scale));
height: calc(11px * var(--hud-scale));
background: rgba(226, 232, 240, 0.9);
transition: transform 0.18s ease, background 0.18s ease;
}
.motion-debug-toggle input:checked + .motion-debug-toggle-track {
border-color: rgba(57, 229, 140, 0.38);
background: rgba(20, 184, 166, 0.24);
}
.motion-debug-toggle input:checked + .motion-debug-toggle-track::after {
transform: translateX(calc(13px * var(--hud-scale)));
background: #39e58c;
}
.layout-mode-mobile .earth-mobile-motion-debug-mount {
display: block;
width: 100%;
min-width: 0;
}
.layout-mode-mobile .earth-mobile-motion-debug-mount > .hud-panel-motion-debug {
position: relative !important;
inset: auto !important;
left: auto !important;
right: auto !important;
top: auto !important;
bottom: auto !important;
transform: none !important;
width: 100% !important;
max-width: none !important;
margin: 0;
box-shadow: none;
pointer-events: auto;
}
.layout-mode-mobile .earth-mobile-motion-debug-mount .hud-panel-drag-handle {
cursor: default;
}
@media (max-width: 960px) {
.earth-settings-sheet {
top: 24px;

View File

@@ -309,6 +309,38 @@
<div id="error-message" class="earth-error-message" aria-live="assertive" aria-atomic="true"></div>
<div id="motion-debug-panel" class="hud-panel hud-panel-motion-debug hud-panel-draggable hud-panel-hidden" data-panel-key="motion-debug-panel" aria-live="polite">
<div class="motion-debug-header hud-panel__header hud-panel-drag-handle">
<div class="motion-debug-title hud-panel-title">
<span class="material-symbols-rounded" aria-hidden="true">sensors</span>
<span>动捕调试</span>
</div>
<div class="motion-debug-header-actions">
<span id="motion-debug-status" class="motion-debug-status">动捕未连接</span>
<button type="button" class="hud-panel-close hud-panel__action hud-panel__action--close" data-motion-debug-close aria-label="关闭动捕调试面板">
<span class="material-symbols-rounded" aria-hidden="true">close</span>
</button>
</div>
</div>
<div class="motion-debug-body hud-panel__body">
<canvas id="motion-debug-canvas" class="motion-debug-canvas" width="320" height="220"></canvas>
<div class="motion-debug-footer">
<span>匹配动作</span>
<strong id="motion-debug-match">等待骨架数据</strong>
</div>
<label class="motion-debug-toggle">
<input type="checkbox" data-motion-skeleton-only-toggle>
<span class="motion-debug-toggle-track"></span>
<span>只显示骨骼</span>
</label>
<label class="motion-debug-toggle">
<input id="motion-debug-pause" type="checkbox" data-motion-recognition-pause-toggle>
<span class="motion-debug-toggle-track"></span>
<span>停止匹配动作</span>
</label>
</div>
</div>
<div id="right-toolbar-group" class="earth-toolbar-group">
<div id="control-toolbar" class="earth-toolbar">
<div id="toolbar-cluster" class="earth-toolbar-cluster is-collapsed">
@@ -633,6 +665,10 @@
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">live_tv</span>
<span class="earth-mobile-drawer-tab-label">TV</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="motion" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">sensors</span>
<span class="earth-mobile-drawer-tab-label">动捕</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">tune</span>
<span class="earth-mobile-drawer-tab-label">设置</span>
@@ -788,6 +824,15 @@
</div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="motion">
<div class="earth-mobile-page earth-mobile-page--motion">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Motion Capture</span>
<span class="earth-mobile-page-summary">查看浏览器摄像头画面、骨架连线和当前匹配动作</span>
</div>
<div id="mobile-motion-debug-mount" class="earth-mobile-motion-debug-mount"></div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="settings">
<div class="earth-mobile-page earth-mobile-page--settings">
<div class="earth-mobile-page-intro">
@@ -799,39 +844,41 @@
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">旋转模式</span>
<span class="earth-mobile-settings-subtitle">巡航模式会按已启用模块的目标队列轮播聚焦</span>
<span class="earth-mobile-settings-subtitle">旋转、巡航和动捕是互斥运行模式</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择旋转模式">
<button type="button" class="earth-mobile-settings-pill is-active" data-rotation-mode="rotate" aria-pressed="true">旋转模式</button>
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="cruise" aria-pressed="false">巡航模式</button>
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="motion" aria-pressed="false">动捕模式</button>
</div>
</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">巡航模块</span>
<span class="earth-mobile-settings-subtitle">选择哪些业务模块参与巡航队列。默认 BGP,新闻可按需加入。</span>
<span class="earth-mobile-settings-subtitle">选择哪些可交互图层参与巡航队列。</span>
</div>
<div class="earth-mobile-settings-chip-group" role="group" aria-label="移动端选择巡航模块">
<button type="button" class="earth-mobile-settings-chip is-active" data-cruise-module-toggle="bgp" aria-pressed="true">BGP</button>
<button type="button" class="earth-mobile-settings-chip" data-cruise-module-toggle="news" aria-pressed="false">新闻</button>
<button type="button" class="earth-mobile-settings-chip" data-cruise-module-toggle="computeCenters" aria-pressed="false">算力</button>
<button type="button" class="earth-mobile-settings-chip" data-cruise-module-toggle="vessels" aria-pressed="false">船只</button>
<button type="button" class="earth-mobile-settings-chip" data-cruise-module-toggle="cables" aria-pressed="false">海缆</button>
<button type="button" class="earth-mobile-settings-chip" data-cruise-module-toggle="satellites" aria-pressed="false">卫星</button>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">卫星</div>
<div class="earth-mobile-settings-title">视图</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">卫星显示风格</span>
<span class="earth-mobile-settings-subtitle">可选自身发光或真实地表覆盖两种选中表现</span>
<span class="earth-mobile-settings-subtitle">选择卫星锁定态使用自身发光或真实地表覆盖范围</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择卫星显示风格">
<button type="button" class="earth-mobile-settings-pill" data-satellite-display-style="self_glow" aria-pressed="false">自身发光</button>
<button type="button" class="earth-mobile-settings-pill is-active" data-satellite-display-style="ground_footprint" aria-pressed="true">真实地表覆盖</button>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">视图</div>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">日夜模式</span>
@@ -842,6 +889,32 @@
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<label
class="earth-mobile-settings-card"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">动捕调试模式</span>
<span class="earth-mobile-settings-subtitle">显示摄像头识别到的骨架连线和匹配动作</span>
</div>
<span class="earth-mobile-settings-switch">
<input type="checkbox" data-motion-debug-toggle>
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<div
class="earth-mobile-settings-card earth-mobile-settings-card--stacked"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">动捕输入源</span>
<span class="earth-mobile-settings-subtitle">网页摄像头无需安装;Motion Agent 用于双摄或网络摄像头</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择动捕输入源">
<button type="button" class="earth-mobile-settings-pill is-active" data-motion-provider="browser_camera" aria-pressed="true">浏览器摄像头</button>
<button type="button" class="earth-mobile-settings-pill" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
</div>
</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">地球默认大小</span>
@@ -973,7 +1046,7 @@
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">旋转模式</span>
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按已启用模块的目标队列轮播聚焦</span>
<span class="earth-settings-item-subtitle">旋转、巡航和动捕是互斥运行模式;巡航按模块轮播,动捕消费手势控制</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式">
<button
@@ -992,12 +1065,20 @@
>
巡航模式
</button>
<button
type="button"
class="earth-settings-segmented-btn"
data-rotation-mode="motion"
aria-pressed="false"
>
动捕模式
</button>
</div>
</div>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">巡航模块</span>
<span class="earth-settings-item-subtitle">选择哪些业务模块参与巡航队列。默认 BGP,新闻会按发生地与时间加入巡航目标并显示新闻卡片。</span>
<span class="earth-settings-item-subtitle">选择哪些可交互图层参与巡航队列。默认 BGP,其他图层按需加入。</span>
</div>
<div class="earth-settings-chip-group" role="group" aria-label="选择巡航模块">
<button
@@ -1016,8 +1097,45 @@
>
新闻
</button>
<button
type="button"
class="earth-settings-chip"
data-cruise-module-toggle="computeCenters"
aria-pressed="false"
>
算力中心
</button>
<button
type="button"
class="earth-settings-chip"
data-cruise-module-toggle="vessels"
aria-pressed="false"
>
船只
</button>
<button
type="button"
class="earth-settings-chip"
data-cruise-module-toggle="cables"
aria-pressed="false"
>
海缆
</button>
<button
type="button"
class="earth-settings-chip"
data-cruise-module-toggle="satellites"
aria-pressed="false"
>
卫星
</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">视图</div>
<div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">卫星显示风格</span>
@@ -1042,11 +1160,6 @@
</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">视图</div>
<div class="earth-settings-list">
<label class="earth-settings-item" for="toggle-daynight">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">日夜模式</span>
@@ -1057,6 +1170,33 @@
<span class="earth-settings-switch-track"></span>
</span>
</label>
<label
class="earth-settings-item"
for="toggle-motion-debug"
data-gatekeeper-permission="earth.motion_debug"
>
<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-motion-debug" type="checkbox" data-motion-debug-toggle>
<span class="earth-settings-switch-track"></span>
</span>
</label>
<div
class="earth-settings-item earth-settings-item--stacked"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-settings-copy">
<span class="earth-settings-item-title">动捕输入源</span>
<span class="earth-settings-item-subtitle">浏览器摄像头适合网页/SaaS;Motion Agent 适合双摄、RTSP/HTTP 和客户端</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择动捕输入源">
<button type="button" class="earth-settings-segmented-btn is-active" data-motion-provider="browser_camera" aria-pressed="true">浏览器摄像头</button>
<button type="button" class="earth-settings-segmented-btn" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
</div>
</div>
<label class="earth-settings-item" for="toggle-view-layers">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">图层控制</span>

View File

@@ -200,6 +200,7 @@ const COMPUTE_CENTER_LOCATION_SOURCE_LABELS = {
source_coordinates: "源数据自带坐标",
ror_organization_registry: "ROR 组织注册 API",
nominatim_online_geocode: "Nominatim 在线搜索",
llm_location_factcheck: "LLM factcheck 兜底",
};
export function formatComputeCenterLocationSource(markerData) {
@@ -369,9 +370,6 @@ export async function loadComputeCenters(_scene, earth) {
const features = Array.isArray(payload?.features) ? payload.features : [];
const unresolved = Array.isArray(payload?.unresolved) ? payload.unresolved : [];
clearComputeCenterData(earth);
unresolvedComputeCenters = unresolved;
const markerData = spreadComputeCenterPositions(
features
.map((feature) => buildComputeCenterMarkerData(feature))
@@ -379,14 +377,21 @@ export async function loadComputeCenters(_scene, earth) {
)
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers);
let nextSupercomputerCount = 0;
let nextGpuClusterCount = 0;
markerData.forEach((item) => {
if (item.site_type === "supercomputer") {
supercomputerCount += 1;
nextSupercomputerCount += 1;
} else {
gpuClusterCount += 1;
nextGpuClusterCount += 1;
}
});
await computeCenterIconLayer.preloadAssets(markerData);
clearComputeCenterData(earth);
unresolvedComputeCenters = unresolved;
supercomputerCount = nextSupercomputerCount;
gpuClusterCount = nextGpuClusterCount;
computeCenterIconLayer.setData(markerData);
computeCenterIconLayer.attach(earth);
computeCenterIconLayer.setVisible(showComputeCenters);

View File

@@ -17,11 +17,16 @@ export const CONFIG = {
export const ROTATION_MODE = {
ROTATE: "rotate",
CRUISE: "cruise",
MOTION: "motion",
};
export const CRUISE_MODULES = {
BGP: "bgp",
NEWS: "news",
COMPUTE_CENTERS: "computeCenters",
VESSELS: "vessels",
CABLES: "cables",
SATELLITES: "satellites",
};
export const DEFAULT_CRUISE_MODULES = [CRUISE_MODULES.BGP];

View File

@@ -64,7 +64,13 @@ import {
getShowVessels,
getVesselCount,
} from "./vessels.js";
import { ensureTVPanelReady, isTVPanelVisible, setTVPanelVisible } from "./tv.js";
import {
ensureTVPanelReady,
getActiveTVTab,
isTVPanelVisible,
setActiveTVTab,
setTVPanelVisible,
} from "./tv.js";
import { createHUDPanel } from "./hud-panels.js";
import {
ensureNewsPanelReady,
@@ -82,6 +88,10 @@ import {
setLayerButtonState,
updateLayerButtonState,
} from "./layer-button-state.js";
import {
DEFAULT_MOTION_PROVIDER,
normalizeMotionProvider,
} from "./motion-protocol.js";
export let autoRotate = true;
export let zoomLevel = 1.0;
@@ -90,6 +100,9 @@ export let layoutExpanded = false;
export let rotationMode = ROTATION_MODE.ROTATE;
let dayNightEnabled = true;
let defaultEarthZoom = CONFIG.defaultViewZoom;
let motionDebugEnabled = false;
let motionProvider = DEFAULT_MOTION_PROVIDER;
let motionDebugSkeletonOnly = false;
let activeCamera = null;
let settingsApplyPromise = Promise.resolve();
@@ -122,10 +135,14 @@ 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 = 5;
const EARTH_SETTINGS_VERSION = 9;
const GRID_LINES_DEFAULT_VERSION = 3;
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
const MEDIA_PANEL_DEFAULT_VERSION = 5;
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 DEFAULT_EARTH_ZOOM_STEP = 0.01;
const ZOOM_STATUS_UPDATE_INTERVAL_MS = 90;
let settingsModalTimer = null;
@@ -151,6 +168,18 @@ const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES));
const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set(
Object.values(SATELLITE_DISPLAY_STYLES),
);
const CRUISE_MODULE_LABELS = {
[CRUISE_MODULES.BGP]: "BGP",
[CRUISE_MODULES.NEWS]: "新闻",
[CRUISE_MODULES.COMPUTE_CENTERS]: "算力中心",
[CRUISE_MODULES.VESSELS]: "船只",
[CRUISE_MODULES.CABLES]: "海缆",
[CRUISE_MODULES.SATELLITES]: "卫星",
};
function normalizeMediaPanelActiveTab(tab) {
return tab === "news" ? "news" : "live";
}
function detectLayoutMode() {
const width = window.innerWidth;
@@ -278,7 +307,7 @@ function closeTransientMobileOverlays({ except = null } = {}) {
&& except !== "settings"
&& isTVPanelVisible()
) {
setTVPanelVisible(false);
setTVPanelVisible(false, { persist: false });
}
}
@@ -750,6 +779,10 @@ function getCurrentSharedSettingsSnapshot() {
terrainOpacity: getTerrainOpacity(),
dayNightEnabled,
defaultEarthZoom,
motionDebugEnabled,
motionProvider,
motionDebugSkeletonOnly,
mediaPanelActiveTab: normalizeMediaPanelActiveTab(getActiveTVTab()),
};
}
@@ -793,6 +826,13 @@ function cloneEarthSettings(settings) {
terrainOpacity: settings.shared.terrainOpacity,
dayNightEnabled: settings.shared.dayNightEnabled,
defaultEarthZoom: settings.shared.defaultEarthZoom,
motionDebugEnabled: settings.shared.motionDebugEnabled,
motionProvider: normalizeMotionProvider(
settings.shared.motionProvider,
DEFAULT_MOTION_PROVIDER,
),
motionDebugSkeletonOnly: Boolean(settings.shared.motionDebugSkeletonOnly),
mediaPanelActiveTab: normalizeMediaPanelActiveTab(settings.shared.mediaPanelActiveTab),
layerVisibility: { ...(settings.shared.layerVisibility || {}) },
},
views: {
@@ -865,8 +905,9 @@ function normalizeEarthSettings(rawSettings, defaults) {
}
const nextRotationMode =
sharedSettings?.rotationMode === ROTATION_MODE.CRUISE
? ROTATION_MODE.CRUISE
sharedSettings?.rotationMode === ROTATION_MODE.CRUISE ||
sharedSettings?.rotationMode === ROTATION_MODE.MOTION
? sharedSettings.rotationMode
: defaults.shared.rotationMode;
const requestedCruiseModules = Array.isArray(sharedSettings?.cruiseModules)
? sharedSettings.cruiseModules
@@ -894,6 +935,24 @@ function normalizeEarthSettings(rawSettings, defaults) {
const nextDefaultEarthZoom = clampEarthZoomLevel(
sharedSettings?.defaultEarthZoom ?? defaults.shared.defaultEarthZoom,
);
const nextMotionDebugEnabled =
(rawSettings?.version || 0) >= MOTION_DEBUG_DEFAULT_VERSION &&
typeof sharedSettings?.motionDebugEnabled === "boolean"
? sharedSettings.motionDebugEnabled
: defaults.shared.motionDebugEnabled;
const nextMotionProvider =
(rawSettings?.version || 0) >= MOTION_PROVIDER_DEFAULT_VERSION
? normalizeMotionProvider(sharedSettings?.motionProvider, defaults.shared.motionProvider)
: defaults.shared.motionProvider;
const nextMotionDebugSkeletonOnly =
(rawSettings?.version || 0) >= MOTION_DEBUG_SKELETON_ONLY_DEFAULT_VERSION &&
typeof sharedSettings?.motionDebugSkeletonOnly === "boolean"
? sharedSettings.motionDebugSkeletonOnly
: defaults.shared.motionDebugSkeletonOnly;
const nextMediaPanelActiveTab =
(rawSettings?.version || 0) >= MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION
? normalizeMediaPanelActiveTab(sharedSettings?.mediaPanelActiveTab)
: normalizeMediaPanelActiveTab(defaults.shared.mediaPanelActiveTab);
return {
version: EARTH_SETTINGS_VERSION,
@@ -909,6 +968,10 @@ function normalizeEarthSettings(rawSettings, defaults) {
: defaults.shared.terrainOpacity,
dayNightEnabled: nextDayNightEnabled,
defaultEarthZoom: nextDefaultEarthZoom,
motionDebugEnabled: nextMotionDebugEnabled,
motionProvider: nextMotionProvider,
motionDebugSkeletonOnly: nextMotionDebugSkeletonOnly,
mediaPanelActiveTab: nextMediaPanelActiveTab,
},
views: {
desktop: {
@@ -921,6 +984,43 @@ function normalizeEarthSettings(rawSettings, defaults) {
};
}
function syncMotionDebugToggle(nextEnabled = motionDebugEnabled) {
document.querySelectorAll("[data-motion-debug-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = Boolean(nextEnabled);
}
});
}
function syncMotionProviderControls(nextProvider = motionProvider) {
document.querySelectorAll("[data-motion-provider]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const active = normalizeMotionProvider(button.dataset.motionProvider) === nextProvider;
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", active ? "true" : "false");
});
}
function syncMotionDebugSkeletonOnlyToggle(nextEnabled = motionDebugSkeletonOnly) {
document.querySelectorAll("[data-motion-skeleton-only-toggle]").forEach((input) => {
if (input instanceof HTMLInputElement) {
input.checked = Boolean(nextEnabled);
}
});
}
function dispatchMotionSettingsChange() {
window.dispatchEvent(
new CustomEvent("earth:motion-debug-mode-change", {
detail: {
enabled: motionDebugEnabled,
provider: motionProvider,
skeletonOnly: motionDebugSkeletonOnly,
},
}),
);
}
function getPersistedLayers() {
return getDisplayLayerDefinitions().filter((layer) => layer.persist !== false);
}
@@ -974,6 +1074,13 @@ function syncEarthSettingsStateFromRuntime() {
return nextSettings;
}
function ensureMutableEarthSettingsState() {
earthSettingsState = cloneEarthSettings(
earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()),
);
return earthSettingsState;
}
function persistEarthSettings() {
if (!canUseLocalStorage()) return;
try {
@@ -1051,9 +1158,7 @@ export function setCruiseModules(nextModules, { persist = true, suppressStatus =
return normalizedModules;
}
earthSettingsState = cloneEarthSettings(
earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()),
);
ensureMutableEarthSettingsState();
earthSettingsState.shared.cruiseModules = [...normalizedModules];
syncCruiseModuleControls();
dispatchCruiseModulesChange();
@@ -1063,9 +1168,7 @@ export function setCruiseModules(nextModules, { persist = true, suppressStatus =
}
if (!suppressStatus) {
const labels = normalizedModules.map((moduleId) =>
moduleId === CRUISE_MODULES.NEWS ? "新闻" : "BGP",
);
const labels = normalizedModules.map((moduleId) => CRUISE_MODULE_LABELS[moduleId] || moduleId);
showStatusMessage(`巡航模块已切换为:${labels.join(" + ")}`, "info");
}
@@ -1086,9 +1189,7 @@ export function setSatelliteDisplayStyle(
return normalizedStyle;
}
earthSettingsState = cloneEarthSettings(
earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()),
);
ensureMutableEarthSettingsState();
earthSettingsState.shared.satelliteDisplayStyle = normalizedStyle;
applySatelliteDisplayStyle(normalizedStyle);
syncSatelliteDisplayStyleControls();
@@ -1184,6 +1285,19 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
persist: false,
applyToCurrentView: true,
});
setMotionDebugEnabled(settings.shared.motionDebugEnabled, {
persist: false,
suppressStatus: true,
});
setMotionProvider(settings.shared.motionProvider, {
persist: false,
suppressStatus: true,
});
setMotionDebugSkeletonOnly(settings.shared.motionDebugSkeletonOnly, {
persist: false,
suppressStatus: true,
});
setActiveTVTab(settings.shared.mediaPanelActiveTab);
if (!applyLayers) {
const layerVisibility = { ...(settings.shared.layerVisibility || {}) };
@@ -1199,6 +1313,104 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
});
}
export function getMotionDebugEnabled() {
return motionDebugEnabled;
}
export function getMotionProvider() {
return motionProvider;
}
export function getMotionDebugSkeletonOnly() {
return motionDebugSkeletonOnly;
}
export function setMotionDebugEnabled(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const requested = Boolean(nextEnabled);
const normalized = requested && rotationMode === ROTATION_MODE.MOTION;
const changed = motionDebugEnabled !== normalized;
motionDebugEnabled = normalized;
syncMotionDebugToggle(motionDebugEnabled);
ensureMutableEarthSettingsState();
earthSettingsState.shared.motionDebugEnabled = motionDebugEnabled;
if (changed) {
dispatchMotionSettingsChange();
}
if (persist) {
persistEarthSettings();
}
if (!suppressStatus && changed) {
showStatusMessage(
motionDebugEnabled ? "动捕调试模式已开启" : "动捕调试模式已关闭",
"info",
);
} else if (!suppressStatus && requested && rotationMode !== ROTATION_MODE.MOTION) {
showStatusMessage("请先切换到动捕模式再打开调试面板", "info");
}
return motionDebugEnabled;
}
export function setMotionProvider(
nextProvider,
{ persist = true, suppressStatus = false } = {},
) {
const normalized = normalizeMotionProvider(nextProvider, DEFAULT_MOTION_PROVIDER);
const changed = motionProvider !== normalized;
motionProvider = normalized;
syncMotionProviderControls(motionProvider);
ensureMutableEarthSettingsState();
earthSettingsState.shared.motionProvider = motionProvider;
if (changed) {
dispatchMotionSettingsChange();
}
if (persist) {
persistEarthSettings();
}
if (!suppressStatus && changed) {
showStatusMessage(
motionProvider === "motion_agent"
? "动捕输入源已切换为 Motion Agent"
: "动捕输入源已切换为浏览器摄像头",
"info",
);
}
return motionProvider;
}
export function setMotionDebugSkeletonOnly(
nextEnabled,
{ persist = true, suppressStatus = false } = {},
) {
const normalized = Boolean(nextEnabled);
const changed = motionDebugSkeletonOnly !== normalized;
motionDebugSkeletonOnly = normalized;
syncMotionDebugSkeletonOnlyToggle(motionDebugSkeletonOnly);
ensureMutableEarthSettingsState();
earthSettingsState.shared.motionDebugSkeletonOnly = motionDebugSkeletonOnly;
if (changed) {
dispatchMotionSettingsChange();
}
if (persist) {
persistEarthSettings();
}
if (!suppressStatus && changed) {
showStatusMessage(
motionDebugSkeletonOnly ? "动捕调试已切换为只显示骨骼" : "动捕调试已显示实时画面",
"info",
);
}
return motionDebugSkeletonOnly;
}
export async function applyDeferredLayerVisibilitySettings(options = {}) {
const layerVisibility = deferredLayerVisibilitySettings;
deferredLayerVisibilitySettings = null;
@@ -2380,6 +2592,27 @@ function setupSettingsControls() {
});
});
document.querySelectorAll("[data-motion-debug-toggle]").forEach((motionDebugToggle) => {
if (!(motionDebugToggle instanceof HTMLInputElement)) return;
bindListener(motionDebugToggle, "change", () => {
setMotionDebugEnabled(motionDebugToggle.checked);
});
});
document.querySelectorAll("[data-motion-provider]").forEach((motionProviderButton) => {
if (!(motionProviderButton instanceof HTMLButtonElement)) return;
bindListener(motionProviderButton, "click", () => {
setMotionProvider(motionProviderButton.dataset.motionProvider);
});
});
document.querySelectorAll("[data-motion-skeleton-only-toggle]").forEach((motionSkeletonOnlyToggle) => {
if (!(motionSkeletonOnlyToggle instanceof HTMLInputElement)) return;
bindListener(motionSkeletonOnlyToggle, "change", () => {
setMotionDebugSkeletonOnly(motionSkeletonOnlyToggle.checked);
});
});
const mobileSettingsReset = document.getElementById("mobile-settings-reset");
bindListener(mobileSettingsReset, "click", () => {
resetEarthSettings();
@@ -2392,6 +2625,9 @@ function setupSettingsControls() {
syncCruiseModuleControls();
syncSatelliteDisplayStyleControls();
syncDayNightToggle(dayNightEnabled);
syncMotionDebugToggle(motionDebugEnabled);
syncMotionProviderControls(motionProvider);
syncMotionDebugSkeletonOnlyToggle(motionDebugSkeletonOnly);
}
function setupHudPanelControls() {
@@ -2732,10 +2968,28 @@ export async function setupControls(camera, renderer, scene, earth) {
}
});
bindListener(window, "earth:tv-visibility-change", (event) => {
if (
event instanceof CustomEvent &&
typeof event.detail?.visible === "boolean" &&
event.detail.persist !== false
) {
const scope = getSettingsViewportScope();
ensureMutableEarthSettingsState();
if (earthSettingsState.views?.[scope]?.panelVisibility) {
earthSettingsState.views[scope].panelVisibility["media-panel"] = event.detail.visible;
}
persistEarthSettings();
}
if (event instanceof CustomEvent && event.detail?.visible) {
closeTransientMobileOverlays({ except: "media" });
}
});
bindListener(window, "earth:tv-tab-change", (event) => {
if (!(event instanceof CustomEvent)) return;
ensureMutableEarthSettingsState();
earthSettingsState.shared.mediaPanelActiveTab = normalizeMediaPanelActiveTab(event.detail?.tab);
persistEarthSettings();
});
// No longer auto-navigates to details tab on mobile — popup handles the display.
// Drawer details tab is opened explicitly via earth:open-details-tab when user taps popup.
}
@@ -2956,7 +3210,12 @@ function setupRotateControls(camera) {
bindListener(rotateBtn, "click", () => {
const isRotating = toggleAutoRotate();
const label = rotationMode === ROTATION_MODE.CRUISE ? "巡航" : "自动旋转";
const label =
rotationMode === ROTATION_MODE.CRUISE
? "巡航"
: rotationMode === ROTATION_MODE.MOTION
? "动捕"
: "自动旋转";
showStatusMessage(isRotating ? `${label}已开启` : `${label}已暂停`, "info");
});
@@ -3129,6 +3388,22 @@ export function getStartupLoadLayers() {
.map((definition) => ({ ...definition }));
}
export function getVisibleMotionLayerDefinitions() {
if (layerRegistry.size === 0) {
initializeLayerRegistry();
}
const motionLayerIds = new Set(["cables", "computeCenters", "bgp", "vessels", "satellites"]);
return getDisplayLayerDefinitions()
.filter((definition) => motionLayerIds.has(definition.id))
.filter((definition) => Boolean(definition.getVisible?.()))
.map((definition) => ({
id: definition.id,
label: definition.label,
meta: definition.meta,
}));
}
function setupTerrainControls() {
initializeLayerRegistry();
const container = document.getElementById("container");
@@ -3645,7 +3920,9 @@ export function getAutoRotate() {
}
function getRotationModeLabel(mode = rotationMode) {
return mode === ROTATION_MODE.CRUISE ? "巡航模式" : "旋转模式";
if (mode === ROTATION_MODE.CRUISE) return "巡航模式";
if (mode === ROTATION_MODE.MOTION) return "动捕模式";
return "旋转模式";
}
function syncRotationModeButtons() {
@@ -3665,7 +3942,11 @@ function updateRotateUI() {
btn.classList.toggle("is-stopped", !autoRotate);
const tooltip = btn.querySelector(".earth-toolbar-tooltip");
const activeLabel =
rotationMode === ROTATION_MODE.CRUISE ? "巡航" : "自动旋转";
rotationMode === ROTATION_MODE.CRUISE
? "巡航"
: rotationMode === ROTATION_MODE.MOTION
? "动捕"
: "自动旋转";
if (tooltip) {
tooltip.textContent = autoRotate ? `暂停${activeLabel}` : `开始${activeLabel}`;
}
@@ -3678,7 +3959,7 @@ function updateRotateUI() {
export function setAutoRotate(value) {
autoRotate = value;
updateRotateUI();
if (rotationMode === ROTATION_MODE.CRUISE) {
if (rotationMode === ROTATION_MODE.CRUISE || rotationMode === ROTATION_MODE.MOTION) {
dispatchRotationModeChange();
}
}
@@ -3687,7 +3968,7 @@ export function toggleAutoRotate() {
autoRotate = !autoRotate;
updateRotateUI();
clearLockedObject();
if (rotationMode === ROTATION_MODE.CRUISE) {
if (rotationMode === ROTATION_MODE.CRUISE || rotationMode === ROTATION_MODE.MOTION) {
dispatchRotationModeChange();
}
return autoRotate;
@@ -3699,22 +3980,26 @@ export function getRotationMode() {
export function setRotationMode(nextMode, { persist = true, suppressStatus = false } = {}) {
const normalizedMode =
nextMode === ROTATION_MODE.CRUISE ? ROTATION_MODE.CRUISE : ROTATION_MODE.ROTATE;
nextMode === ROTATION_MODE.CRUISE
? ROTATION_MODE.CRUISE
: nextMode === ROTATION_MODE.MOTION
? ROTATION_MODE.MOTION
: ROTATION_MODE.ROTATE;
const changed = normalizedMode !== rotationMode;
if (changed && normalizedMode === ROTATION_MODE.CRUISE) {
if (changed && (normalizedMode === ROTATION_MODE.CRUISE || normalizedMode === ROTATION_MODE.MOTION)) {
autoRotate = true;
}
rotationMode = normalizedMode;
if (normalizedMode !== ROTATION_MODE.MOTION && motionDebugEnabled) {
setMotionDebugEnabled(false, { persist, suppressStatus: true });
}
updateRotateUI();
dispatchRotationModeChange();
if (persist) {
persistEarthSettings();
}
if (changed && !suppressStatus) {
showStatusMessage(
normalizedMode === ROTATION_MODE.CRUISE ? "已切换到巡航模式" : "已切换到旋转模式",
"info",
);
showStatusMessage(`已切换到${getRotationModeLabel(normalizedMode)}`, "info");
}
}

View File

@@ -12,6 +12,7 @@ let _hoverGlowLines = null;
let _hoverLines = null;
let _hoveredFeature = null;
let _hoveredGroupKey = null;
let _hoverGeometryCache = new Map();
let _visible = false;
let _landFillEnabled = true;
let _landFillSuppressed = false;
@@ -235,15 +236,62 @@ function setBoundaryLinesDimmed(dimmed) {
_boundaryLines.material.needsUpdate = true;
}
function clearHoverLineGeometries() {
if (_hoverGlowLines) _hoverGlowLines.geometry.setFromPoints([]);
if (_hoverLines) _hoverLines.geometry.setFromPoints([]);
function setHoverLinesVisible(visible) {
const nextVisible = _visible && Boolean(visible);
if (_hoverGlowLines) _hoverGlowLines.visible = nextVisible;
if (_hoverLines) _hoverLines.visible = nextVisible;
}
function featureListToSegments(features, radius) {
return features.flatMap(f => featureToSegments(f.geometry, radius));
}
function makeLineGeometry(points) {
return points.length > 0
? new THREE.BufferGeometry().setFromPoints(points)
: new THREE.BufferGeometry();
}
function markCachedHoverGeometry(geometry) {
if (geometry) geometry.userData.countryBoundaryHoverCached = true;
return geometry;
}
function setLineGeometry(line, geometry) {
if (!line || !geometry || line.geometry === geometry) return;
if (!line.geometry?.userData?.countryBoundaryHoverCached) {
line.geometry?.dispose?.();
}
line.geometry = geometry;
}
function getHoverGeometries(groupKey, features) {
const cacheKey = groupKey || features[0] || "__empty__";
const cached = _hoverGeometryCache.get(cacheKey);
if (cached) return cached;
const coreRadius = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset;
const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset;
const geometries = {
core: markCachedHoverGeometry(
makeLineGeometry(featureListToSegments(features, coreRadius)),
),
glow: markCachedHoverGeometry(
makeLineGeometry(featureListToSegments(features, glowRadius)),
),
};
_hoverGeometryCache.set(cacheKey, geometries);
return geometries;
}
function disposeHoverGeometryCache() {
_hoverGeometryCache.forEach(({ core, glow }) => {
core?.dispose?.();
glow?.dispose?.();
});
_hoverGeometryCache.clear();
}
// ─── Point-in-polygon (lat/lon space) ─────────────────────────────────────────
function pointInRing(lat, lon, ring) {
@@ -373,14 +421,13 @@ export function toggleCountryBoundaries(
_landMesh.visible = _landFillEnabled && !_landFillSuppressed;
}
if (_boundaryLines) _boundaryLines.visible = _visible;
if (_hoverGlowLines) _hoverGlowLines.visible = _visible;
if (_hoverLines) _hoverLines.visible = _visible;
setHoverLinesVisible(_hoveredFeature);
if (!_visible) {
_hoveredFeature = null;
_hoveredGroupKey = null;
setBoundaryLinesDimmed(false);
clearHoverLineGeometries();
setHoverLinesVisible(false);
}
if (_tintMesh) _tintMesh.visible = _visible && showTint && _tintEnabled;
@@ -417,7 +464,7 @@ export function clearCountryBoundaryHover() {
_hoveredFeature = null;
_hoveredGroupKey = null;
setBoundaryLinesDimmed(false);
clearHoverLineGeometries();
setHoverLinesVisible(false);
}
/**
@@ -437,18 +484,14 @@ export function updateCountryBoundaryHover(coords) {
if (_hoverLines) {
if (!found) {
setBoundaryLinesDimmed(false);
clearHoverLineGeometries();
setHoverLinesVisible(false);
} else {
setBoundaryLinesDimmed(true);
const highlightFeatures = getHighlightFeatures(found);
const coreRadius = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset;
const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset;
if (_hoverGlowLines) {
const glowPts = featureListToSegments(highlightFeatures, glowRadius);
_hoverGlowLines.geometry.setFromPoints(glowPts);
}
const corePts = featureListToSegments(highlightFeatures, coreRadius);
_hoverLines.geometry.setFromPoints(corePts);
const geometries = getHoverGeometries(groupKey, highlightFeatures);
setLineGeometry(_hoverGlowLines, geometries.glow);
setLineGeometry(_hoverLines, geometries.core);
setHoverLinesVisible(true);
}
}
}
@@ -464,7 +507,9 @@ export function clearCountryBoundaryData() {
function disposeObj(obj) {
if (!obj) return;
if (_earthObj) _earthObj.remove(obj);
obj.geometry?.dispose();
if (!obj.geometry?.userData?.countryBoundaryHoverCached) {
obj.geometry?.dispose();
}
if (obj.material) {
if (obj.material.map) obj.material.map.dispose();
obj.material.dispose();
@@ -483,6 +528,7 @@ export function clearCountryBoundaryData() {
_landMesh = null;
_tintMesh = null;
_features = [];
disposeHoverGeometryCache();
_loaded = false;
_loadPromise = null;
_visible = false;

View File

@@ -14,6 +14,7 @@ export class CruiseSequencer {
hideItem,
clearCurrent,
onStop,
presentationMode = "auto_advance",
dwellMs = 2400,
transitionGapMs = 24,
}) {
@@ -25,6 +26,7 @@ export class CruiseSequencer {
this.hideItem = hideItem;
this.clearCurrent = clearCurrent;
this.onStop = onStop;
this.presentationMode = presentationMode === "pinned" ? "pinned" : "auto_advance";
this.dwellMs = dwellMs;
this.transitionGapMs = transitionGapMs;
@@ -39,11 +41,12 @@ export class CruiseSequencer {
this.primaryTimerId = null;
this.secondaryTimerId = null;
this.presentationVisible = false;
this.currentItem = null;
}
getCurrentItem() {
if (!this.currentItemId) return null;
return this.getItems().find((item) => this.getItemId(item) === this.currentItemId) || null;
return this.getItems().find((item) => this.getItemId(item) === this.currentItemId) || this.currentItem || null;
}
getCurrentItemId() {
@@ -58,6 +61,10 @@ export class CruiseSequencer {
return this.advanceInFlight || this.presentationVisible;
}
isPinnedMode() {
return this.presentationMode === "pinned";
}
enqueue(itemIds = []) {
if (!Array.isArray(itemIds) || itemIds.length === 0) return;
this.queuedItemIds = Array.from(
@@ -91,12 +98,14 @@ export class CruiseSequencer {
}
if (!preservePresentation) {
this.presentationVisible = false;
this.currentItem = null;
this.clearCurrent?.();
}
}
stop({ preservePresentation = false } = {}) {
this.interruptPresentation({ preservePresentation });
this.currentItem = preservePresentation ? this.currentItem : null;
this.currentItemId = preservePresentation ? this.currentItemId : null;
this.currentIndex = preservePresentation ? this.currentIndex : -1;
this.queuedItemIds = [];
@@ -145,15 +154,11 @@ export class CruiseSequencer {
return items[nextIndex] || items[0] || null;
}
async performAdvance({ interrupt = false } = {}) {
async presentResolvedItem(targetItem, { interrupt = false } = {}) {
if (!this.isActive()) return;
const items = this.getItems();
if (!Array.isArray(items) || items.length === 0) return;
const targetItem = this.resolveNextItem(items);
if (!targetItem) return;
const items = this.getItems();
const token = ++this.sequenceToken;
const context = this.createContext(token);
@@ -161,10 +166,11 @@ export class CruiseSequencer {
this.presentationVisible = false;
this.clearCurrent?.();
this.currentItem = targetItem;
this.currentItemId = this.getItemId(targetItem);
this.currentIndex = items.findIndex(
(item) => this.getItemId(item) === this.currentItemId,
);
this.currentIndex = Array.isArray(items)
? items.findIndex((item) => this.getItemId(item) === this.currentItemId)
: -1;
await this.focusItem?.(targetItem, { interrupt, context });
if (!context.isCurrent()) {
@@ -179,6 +185,10 @@ export class CruiseSequencer {
}
this.presentationVisible = true;
if (this.isPinnedMode()) {
return true;
}
const dwellCompleted = await context.wait(this.dwellMs);
if (!dwellCompleted || !context.isCurrent()) {
this.presentationVisible = false;
@@ -198,6 +208,26 @@ export class CruiseSequencer {
}
void this.advance();
return true;
}
async performAdvance({ interrupt = false } = {}) {
if (!this.isActive()) return;
const items = this.getItems();
if (!Array.isArray(items) || items.length === 0) return;
const targetItem = this.resolveNextItem(items);
return this.presentResolvedItem(targetItem, { interrupt });
}
async presentSpecificItem(item, { interrupt = false } = {}) {
if (!this.isActive() || !item) return false;
this.advanceLoopToken += 1;
this.advanceQueued = false;
this.advanceInterrupt = false;
this.advanceInFlight = false;
return Boolean(await this.presentResolvedItem(item, { interrupt }));
}
async advance({ interrupt = false } = {}) {

View File

@@ -0,0 +1,101 @@
import { describe, expect, test } from "bun:test";
import { CruiseSequencer } from "./cruise-sequencer.js";
function installWindow() {
globalThis.window = {
requestAnimationFrame: (callback) => setTimeout(callback, 0),
setTimeout,
clearTimeout,
};
}
function wait(ms = 8) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function createSequencer(options = {}) {
installWindow();
const calls = [];
let active = true;
let items = options.items || [{ id: "one" }, { id: "two" }];
const sequencer = new CruiseSequencer({
isActive: () => active,
getItems: () => items,
getItemId: (item) => item.id,
dwellMs: options.dwellMs ?? 1,
transitionGapMs: options.transitionGapMs ?? 1,
presentationMode: options.presentationMode,
clearCurrent: () => calls.push("clear"),
focusItem: async (item) => calls.push(`focus:${item.id}`),
presentItem: async (item) => {
calls.push(`present:${item.id}`);
return true;
},
hideItem: async (item) => {
calls.push(`hide:${item.id}`);
if (options.stopAfterHide) active = false;
},
});
return {
calls,
items,
sequencer,
setItems: (nextItems) => {
items = nextItems;
},
};
}
describe("CruiseSequencer presentation modes", () => {
test("auto_advance keeps existing dwell, hide, and advance behavior", async () => {
const { calls, sequencer } = createSequencer({ stopAfterHide: true });
await sequencer.advance();
await wait();
expect(calls).toContain("focus:one");
expect(calls).toContain("present:one");
expect(calls).toContain("hide:one");
});
test("pinned mode presents without auto hiding or advancing", async () => {
const { calls, sequencer } = createSequencer({ presentationMode: "pinned" });
await sequencer.advance();
await wait();
expect(calls).toEqual(["clear", "focus:one", "present:one"]);
expect(sequencer.isPresentationPinned()).toBe(true);
});
test("presentSpecificItem directly presents the requested item", async () => {
const { calls, items, sequencer } = createSequencer({ presentationMode: "pinned" });
const presented = await sequencer.presentSpecificItem(items[1], { interrupt: true });
expect(presented).toBe(true);
expect(calls).toEqual(["clear", "focus:two", "present:two"]);
expect(sequencer.getCurrentItemId()).toBe("two");
});
test("pinned mode keeps the presented item even when the live queue no longer contains it", async () => {
const { items, sequencer, setItems } = createSequencer({ presentationMode: "pinned" });
await sequencer.presentSpecificItem(items[1], { interrupt: true });
setItems([]);
expect(sequencer.getCurrentItem()).toEqual({ id: "two" });
});
test("stop can preserve or clear a pinned presentation", async () => {
const { sequencer } = createSequencer({ presentationMode: "pinned" });
await sequencer.advance();
sequencer.stop({ preservePresentation: true });
expect(sequencer.isPresentationPinned()).toBe(true);
sequencer.stop();
expect(sequencer.isPresentationPinned()).toBe(false);
});
});

View File

@@ -8,6 +8,7 @@ let typewriterToken = 0;
let pendingMobileDetailState = null;
let mobileDetailsListenerBound = false;
let renderedMobileDetailKey = null;
const locationCollectStateCache = new Map();
const IDENTIFIER_FIELD_KEYS = new Set([
'mmsi',
'mmsi_display',
@@ -17,6 +18,53 @@ const IDENTIFIER_FIELD_KEYS = new Set([
]);
const MAX_VESSEL_MEDIA_TILES = 4;
function getLocationCollectCacheKey(context) {
const entityType = context?.entityType || 'unknown';
const entityId = context?.entityId || context?.sourceId || '';
if (!entityId) return '';
return `${entityType}:${entityId}`;
}
function getLocationCollectState(contextOrKey) {
const key = typeof contextOrKey === 'string'
? contextOrKey
: getLocationCollectCacheKey(contextOrKey);
return key ? locationCollectStateCache.get(key) || null : null;
}
function setLocationCollectState(contextOrKey, patch = {}) {
const key = typeof contextOrKey === 'string'
? contextOrKey
: getLocationCollectCacheKey(contextOrKey);
if (!key) return null;
const previous = locationCollectStateCache.get(key) || {};
const next = {
...previous,
...patch,
updatedAt: Date.now(),
};
locationCollectStateCache.set(key, next);
updateLocationCollectDomFromState(key);
return next;
}
function clearLocationCollectState(contextOrKey) {
const key = typeof contextOrKey === 'string'
? contextOrKey
: getLocationCollectCacheKey(contextOrKey);
if (!key) return;
locationCollectStateCache.delete(key);
updateLocationCollectDomFromState(key);
}
function updateLocationCollectDomFromState(key) {
if (!key) return;
const state = getLocationCollectState(key);
document.querySelectorAll(`[data-collect-cache-key="${escapeCssIdentifier(key)}"]`).forEach((root) => {
hydrateLocationCollectRoot(root, state);
});
}
function formatInfoCardValue(field, rawValue) {
if (rawValue === undefined || rawValue === null || rawValue === '') {
return '-';
@@ -43,6 +91,13 @@ function escapeInfoCardHtml(value) {
}[char]));
}
function escapeCssIdentifier(value) {
if (window.CSS && typeof window.CSS.escape === 'function') {
return window.CSS.escape(String(value));
}
return String(value).replace(/["\\]/g, '\\$&');
}
function getNewsSummaryText(data) {
return (data?.summary || data?.title || '').trim() || '暂无摘要';
}
@@ -300,18 +355,55 @@ function renderLocationCollectSection(context) {
const buttonLabel = context.needsConfirmation
? '重新自动采集坐标'
: '自动采集坐标候选';
const cacheKey = getLocationCollectCacheKey(context);
const cached = getLocationCollectState(cacheKey);
return `
<div class="info-card-compute-collect" data-collect-entity-id="${context.entityId}" data-collect-entity-type="${context.entityType}">
<div class="info-card-compute-collect" data-collect-entity-id="${context.entityId}" data-collect-entity-type="${context.entityType}" data-collect-cache-key="${escapeInfoCardHtml(cacheKey)}">
<button type="button" class="info-card-compute-collect-button" data-collect-action="run">
<span class="material-symbols-rounded" aria-hidden="true">explore</span>
<span>${buttonLabel}</span>
</button>
<div class="info-card-compute-collect-status" data-collect-status></div>
<div class="info-card-compute-collect-candidates" data-collect-candidates></div>
<div class="info-card-compute-collect-status" data-collect-status>${escapeInfoCardHtml(cached?.statusText || '')}</div>
<div class="info-card-compute-collect-candidates" data-collect-candidates>
${renderCachedCollectCandidates(cached)}
</div>
</div>
`;
}
function renderCachedCollectCandidates(state) {
const candidates = Array.isArray(state?.candidates) ? state.candidates : [];
if (!candidates.length) return '';
return candidates
.slice(0, 5)
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0))
.join('');
}
function hydrateLocationCollectRoot(root, state) {
if (!(root instanceof HTMLElement)) return;
const statusEl = root.querySelector('[data-collect-status], [data-unresolved-status]');
const candidatesEl = root.querySelector('[data-collect-candidates], [data-unresolved-candidates]');
const button = root.querySelector('[data-collect-action="run"], [data-unresolved-collect]');
if (statusEl) statusEl.textContent = state?.statusText || '';
if (candidatesEl) candidatesEl.innerHTML = renderCachedCollectCandidates(state);
if (button instanceof HTMLButtonElement) button.disabled = state?.loading === true;
}
function formatLocationCollectFailure(result) {
const regularReason = result?.failure_reason || '常规来源没有可用坐标候选';
const llmReason = result?.llm_failure_reason;
if (llmReason) {
return `常规来源无结果;LLM 兜底未生成可用候选:${llmReason}`;
}
const attempted = Array.isArray(result?.attempted_queries) ? result.attempted_queries : [];
const attemptedLlm = attempted.some((query) => String(query || '').startsWith('llm_factcheck:'));
if (attemptedLlm) {
return `常规来源无结果;LLM 兜底已尝试但没有返回可用候选。${regularReason}`;
}
return regularReason;
}
function bindLocationCollectControls(content, context) {
const collectRoot = content.querySelector('[data-collect-entity-id]');
if (!collectRoot) return;
@@ -319,30 +411,52 @@ function bindLocationCollectControls(content, context) {
const statusEl = collectRoot.querySelector('[data-collect-status]');
const candidatesEl = collectRoot.querySelector('[data-collect-candidates]');
if (!button) return;
const cachedState = getLocationCollectState(context);
if (cachedState) {
hydrateLocationCollectRoot(collectRoot, cachedState);
bindCandidatePreviewButtons(candidatesEl, context);
bindCandidateSaveButtons(candidatesEl, context, statusEl);
}
button.addEventListener('click', async (event) => {
event.stopPropagation();
button.disabled = true;
statusEl.textContent = '正在采集坐标候选...';
candidatesEl.innerHTML = '';
setLocationCollectState(context, {
loading: true,
statusText: '正在采集坐标候选...',
candidates: [],
});
try {
const result = await context.collect();
if (!result?.success) {
statusEl.textContent = `未能采集到坐标:${result?.failure_reason || '未知原因'}`;
setLocationCollectState(context, {
loading: false,
statusText: `未能采集到坐标:${formatLocationCollectFailure(result)}`,
candidates: [],
result,
});
return;
}
const candidates = Array.isArray(result.candidates) ? result.candidates : [];
statusEl.textContent = `共找到 ${candidates.length} 个候选位置`;
candidatesEl.innerHTML = candidates
.slice(0, 5)
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0))
.join('');
setLocationCollectState(context, {
loading: false,
statusText: `共找到 ${candidates.length} 个候选位置`,
candidates,
result,
});
bindCandidatePreviewButtons(candidatesEl, context);
bindCandidateSaveButtons(candidatesEl, context, statusEl);
} catch (error) {
console.error('collect-location failed', error);
statusEl.textContent = `采集失败:${error?.message || error}`;
setLocationCollectState(context, {
loading: false,
statusText: `采集失败:${error?.message || error}`,
candidates: [],
});
} finally {
button.disabled = false;
updateLocationCollectDomFromState(getLocationCollectCacheKey(context));
bindCandidatePreviewButtons(candidatesEl, context);
bindCandidateSaveButtons(candidatesEl, context, statusEl);
}
}, { once: false });
}
@@ -384,6 +498,8 @@ function getUnresolvedComputeCenterContext(item) {
? item.metadata
: {};
return {
entityType: 'compute_center',
entityId: item?.source_id || item?.id || '',
sourceId: item?.source_id || item?.id || '',
recordId: item?.id || item?.record_id || '',
name: item?.name || item?.title || '未命名算力中心',
@@ -410,11 +526,13 @@ function renderComputeCenterUnresolvedContent(content, data) {
.map((item, index) => {
const context = getUnresolvedComputeCenterContext(item);
const contextJson = JSON.stringify(context).replace(/"/g, '&quot;');
const cacheKey = getLocationCollectCacheKey(context);
const cached = getLocationCollectState(cacheKey);
const meta = [context.site || context.operator, context.city, context.country]
.filter(Boolean)
.join(' · ') || '缺少可用地址字段';
return `
<div class="info-card-unresolved-item" data-unresolved-item>
<div class="info-card-unresolved-item" data-unresolved-item data-collect-cache-key="${escapeInfoCardHtml(cacheKey)}">
<div class="info-card-unresolved-main">
<div class="info-card-unresolved-index">${index + 1}</div>
<div class="info-card-unresolved-copy">
@@ -424,8 +542,10 @@ function renderComputeCenterUnresolvedContent(content, data) {
<button type="button" class="info-card-compute-candidate-preview" data-unresolved-collect
data-context-json="${contextJson}">采集</button>
</div>
<div class="info-card-compute-collect-status" data-unresolved-status></div>
<div class="info-card-compute-collect-candidates" data-unresolved-candidates></div>
<div class="info-card-compute-collect-status" data-unresolved-status>${escapeInfoCardHtml(cached?.statusText || '')}</div>
<div class="info-card-compute-collect-candidates" data-unresolved-candidates>
${renderCachedCollectCandidates(cached)}
</div>
</div>
`;
})
@@ -479,7 +599,17 @@ function removeResolvedUnresolvedItem(content, itemRoot) {
return updateUnresolvedSummary(content);
}
async function collectUnresolvedComputeCenterCandidates(context) {
async function collectUnresolvedComputeCenterCandidates(context, options = {}) {
const cached = getLocationCollectState(context);
if (options.useCached === true && Array.isArray(cached?.candidates) && cached.candidates.length) {
const mod = await import('./compute-centers.js');
return {
mod,
result: cached.result || { success: true, candidates: cached.candidates },
candidates: cached.candidates,
fromCache: true,
};
}
const mod = await import('./compute-centers.js');
const result = await mod.collectComputeCenterLocation(context.sourceId, {
name: context.name,
@@ -494,6 +624,7 @@ async function collectUnresolvedComputeCenterCandidates(context) {
mod,
result,
candidates: Array.isArray(result?.candidates) ? result.candidates : [],
fromCache: false,
};
}
@@ -547,7 +678,12 @@ function bindCandidateSaveButtons(container, context, statusEl) {
if (statusEl) statusEl.textContent = '正在保存所选坐标...';
try {
await context.save(candidate);
if (statusEl) statusEl.textContent = '坐标已保存,正在刷新图层...';
setLocationCollectState(context, {
loading: false,
statusText: '坐标已保存,正在后台刷新图层...',
candidates: [],
});
if (statusEl) statusEl.textContent = '坐标已保存,正在后台刷新图层...';
window.dispatchEvent(
new CustomEvent('earth:compute-center-location-saved', {
detail: {
@@ -557,6 +693,12 @@ function bindCandidateSaveButtons(container, context, statusEl) {
},
}),
);
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}`;
@@ -568,6 +710,25 @@ function bindCandidateSaveButtons(container, context, statusEl) {
}
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 = {
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);
});
content.querySelectorAll('[data-unresolved-collect]').forEach((button) => {
button.addEventListener('click', async (event) => {
event.stopPropagation();
@@ -578,31 +739,57 @@ function bindComputeCenterUnresolvedControls(content) {
if (!context.sourceId || !statusEl || !candidatesEl) return;
button.disabled = true;
statusEl.textContent = '正在采集坐标候选...';
candidatesEl.innerHTML = '';
setLocationCollectState(context, {
loading: true,
statusText: '正在采集坐标候选...',
candidates: [],
});
try {
const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(context);
if (!result?.success) {
statusEl.textContent = `未能采集到坐标:${result?.failure_reason || '未知原因'}`;
setLocationCollectState(context, {
loading: false,
statusText: `未能采集到坐标:${formatLocationCollectFailure(result)}`,
candidates: [],
result,
});
return;
}
statusEl.textContent = `共找到 ${candidates.length} 个候选位置`;
candidatesEl.innerHTML = candidates
.slice(0, 5)
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0))
.join('');
setLocationCollectState(context, {
loading: false,
statusText: `共找到 ${candidates.length} 个候选位置`,
candidates,
result,
});
const actionContext = {
entityType: 'compute_center',
entityId: context.sourceId,
isUnresolved: true,
save: (candidate) => mod.saveComputeCenterLocation(context.sourceId, candidate, context),
};
bindCandidatePreviewButtons(candidatesEl, actionContext);
bindCandidateSaveButtons(candidatesEl, actionContext, statusEl);
} catch (error) {
console.error('collect unresolved compute-center location failed', error);
statusEl.textContent = `采集失败:${error?.message || error}`;
setLocationCollectState(context, {
loading: false,
statusText: `采集失败:${error?.message || error}`,
candidates: [],
});
} 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);
}
});
});
@@ -633,10 +820,13 @@ function bindComputeCenterUnresolvedControls(content) {
statusEl.textContent = `正在采用最高置信候选 ${index + 1}/${pendingItems.length}...`;
}
try {
const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(context);
const { mod, result, candidates, fromCache } = await collectUnresolvedComputeCenterCandidates(
context,
{ useCached: true },
);
if (!result?.success) {
if (itemStatusEl) {
itemStatusEl.textContent = `未找到可采用候选:${result?.failure_reason || '未知原因'}`;
itemStatusEl.textContent = `未找到可采用候选:${formatLocationCollectFailure(result)}`;
}
missedCount += 1;
continue;
@@ -650,6 +840,9 @@ function bindComputeCenterUnresolvedControls(content) {
continue;
}
await mod.saveComputeCenterLocation(context.sourceId, bestCandidate, context);
if (fromCache) {
clearLocationCollectState(context);
}
savedCount += 1;
removeResolvedUnresolvedItem(content, itemRoot);
} catch (error) {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
const DEFAULT_RECONNECT_MS = 1800;
export const DEFAULT_AGENT_URL = "ws://127.0.0.1:8765/ws/gestures";
export function createMotionAgentProvider(options = {}) {
const {
url = DEFAULT_AGENT_URL,
reconnectMs = DEFAULT_RECONNECT_MS,
WebSocketCtor = typeof WebSocket !== "undefined" ? WebSocket : null,
onMessage = () => {},
onState = () => {},
onStatus = () => {},
} = options;
let socket = null;
let reconnectTimer = null;
let disposed = false;
let connected = false;
function emitState(detail = {}) {
onState({
provider: "motion_agent",
connected,
url,
...detail,
});
}
function clearReconnect() {
if (!reconnectTimer) return;
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
function scheduleReconnect() {
if (disposed || reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, reconnectMs);
}
function closeSocket() {
if (!socket) return;
const current = socket;
socket = null;
current.onopen = null;
current.onmessage = null;
current.onerror = null;
current.onclose = null;
try {
current.close();
} catch (_error) {
// Browser WebSocket close can throw during teardown in older engines.
}
}
function connect() {
if (disposed || socket) return;
if (!WebSocketCtor) {
emitState({ error: "websocket_unavailable", message: "当前浏览器不支持 WebSocket" });
return;
}
try {
socket = new WebSocketCtor(url);
} catch (_error) {
emitState({ error: "socket_create_failed", message: "无法创建 Motion Agent 连接" });
scheduleReconnect();
return;
}
socket.onopen = () => {
connected = true;
emitState({ connected: true });
onStatus("动捕 Agent 已连接", "info");
};
socket.onmessage = (rawMessage) => onMessage(rawMessage?.data ?? rawMessage);
socket.onerror = () => {
emitState({ connected: false, error: "socket_error", message: "Motion Agent 连接异常" });
};
socket.onclose = () => {
connected = false;
socket = null;
emitState({ connected: false, message: "Motion Agent 未连接" });
scheduleReconnect();
};
}
return {
provider: "motion_agent",
start() {
if (disposed) return false;
connect();
return true;
},
stop() {
disposed = true;
clearReconnect();
closeSocket();
connected = false;
emitState({ connected: false });
},
isConnected() {
return connected;
},
};
}

View File

@@ -0,0 +1,548 @@
const MEDIAPIPE_TASKS_VERSION = "0.10.35";
const MEDIAPIPE_TASKS_URLS = [
`https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_TASKS_VERSION}/vision_bundle.mjs`,
`https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_TASKS_VERSION}`,
`https://unpkg.com/@mediapipe/tasks-vision@${MEDIAPIPE_TASKS_VERSION}/vision_bundle.mjs`,
];
const MEDIAPIPE_WASM_URL = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_TASKS_VERSION}/wasm`;
const POSE_MODEL_URL =
"https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/latest/pose_landmarker_lite.task";
const FRAME_INTERVAL_MS = 66;
const GESTURE_COOLDOWN_MS = 420;
const VIDEO_METADATA_TIMEOUT_MS = 900;
const LEFT_WRIST_LAYER_DELTA_Y = 0.05;
const HEAD_TILT_DELTA_Y = 0.035;
const ARM_PATTERN_TERMINAL_TOLERANCE_DEG = 32;
const ARM_PATTERN_UPPER_TOLERANCE_DEG = 34;
const ARM_PATTERN_MIN_SEGMENT = 0.045;
const ARM_PATTERN_MIN_SIDE_REACH = 0.06;
const ARM_PATTERN_MIN_VERTICAL_REACH = 0.055;
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;
const CAMERA_CONSTRAINTS = {
video: {
facingMode: "user",
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
};
const POSE_JOINTS = [
[0, "nose"],
[7, "left_ear"],
[8, "right_ear"],
[11, "left_shoulder"],
[12, "right_shoulder"],
[13, "left_elbow"],
[14, "right_elbow"],
[15, "left_wrist"],
[16, "right_wrist"],
];
const POSE_BONES = [
["left_shoulder", "left_elbow"],
["left_elbow", "left_wrist"],
["right_shoulder", "right_elbow"],
["right_elbow", "right_wrist"],
["left_shoulder", "right_shoulder"],
];
function nowMs() {
return Math.round(performance?.now?.() || Date.now());
}
function wallClockMs() {
return Date.now();
}
function waitForVideoMetadata(video) {
if (video.videoWidth > 0 && video.videoHeight > 0) {
return Promise.resolve();
}
return new Promise((resolve) => {
const done = () => resolve();
video.addEventListener?.("loadedmetadata", done, { once: true });
video.addEventListener?.("canplay", done, { once: true });
setTimeout(done, VIDEO_METADATA_TIMEOUT_MS);
});
}
function getUserMediaErrorMessage(error) {
if (error?.name === "NotAllowedError" || error?.name === "PermissionDeniedError") {
return "浏览器摄像头权限被拒绝";
}
if (error?.name === "NotFoundError" || error?.name === "DevicesNotFoundError") {
return "没有找到可用摄像头";
}
if (error?.name === "NotReadableError") {
return "摄像头正被其他程序占用";
}
return `浏览器摄像头启动失败: ${error?.message || String(error)}`;
}
function canUseBrowserCamera(mediaDevices) {
return Boolean(
mediaDevices &&
typeof mediaDevices.getUserMedia === "function",
);
}
function isSecureCameraContext() {
if (typeof window === "undefined") return false;
const hostname = window.location?.hostname || "";
return Boolean(window.isSecureContext || hostname === "localhost" || hostname === "127.0.0.1");
}
function normalizePoseLandmarks(landmarks = []) {
return POSE_JOINTS.map(([index, id]) => {
const point = landmarks[index];
if (!point) return null;
const x = Number(point.x);
const y = Number(point.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return {
id,
x: Math.max(0, Math.min(1, x)),
y: Math.max(0, Math.min(1, y)),
confidence: Math.max(0, Math.min(1, Number(point.visibility ?? point.presence ?? 1))),
};
}).filter(Boolean);
}
function getJoint(joints, id) {
return joints.find((joint) => joint.id === id) || null;
}
function vectorBetween(start, end) {
if (!start || !end) return null;
const dx = end.x - start.x;
const dy = end.y - start.y;
return {
dx,
dy,
length: Math.hypot(dx, dy),
};
}
function vectorAngleDeg(vector) {
return Math.atan2(vector.dy, vector.dx) * 180 / Math.PI;
}
function normalizeAngleDelta(angle, target) {
let delta = angle - target;
while (delta > 180) delta -= 360;
while (delta < -180) delta += 360;
return Math.abs(delta);
}
function isAngleNear(angle, target, toleranceDeg) {
return normalizeAngleDelta(angle, target) <= toleranceDeg;
}
function isHorizontalArm(upperVector) {
if (!upperVector || upperVector.length < ARM_PATTERN_MIN_SEGMENT) return false;
const angle = vectorAngleDeg(upperVector);
return (
isAngleNear(angle, 0, ARM_PATTERN_UPPER_TOLERANCE_DEG) ||
isAngleNear(angle, 180, ARM_PATTERN_UPPER_TOLERANCE_DEG)
);
}
function isTerminalToward(vector, targetAngle) {
if (!vector || vector.length < ARM_PATTERN_MIN_SEGMENT) return false;
return isAngleNear(vectorAngleDeg(vector), targetAngle, ARM_PATTERN_TERMINAL_TOLERANCE_DEG);
}
function getRightArmPattern(rightShoulder, rightElbow, rightWrist) {
const upper = vectorBetween(rightShoulder, rightElbow);
const terminal = vectorBetween(rightElbow, rightWrist);
if (!upper || !terminal) return null;
const intensity = Math.min(1, Math.max(MIN_GESTURE_INTENSITY, terminal.length * ARM_PATTERN_INTENSITY_SCALE));
if (isTerminalToward(terminal, 180) && rightWrist.x < rightShoulder.x - ARM_PATTERN_MIN_SIDE_REACH) {
return { gesture: "rotate_right", confidence: 0.82, intensity };
}
if (isTerminalToward(terminal, 0) && rightWrist.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH) {
return { gesture: "rotate_left", confidence: 0.82, intensity };
}
if (isHorizontalArm(upper) && isTerminalToward(terminal, -90) && rightWrist.y < rightElbow.y - ARM_PATTERN_MIN_VERTICAL_REACH) {
return { gesture: "rotate_up", confidence: 0.8, intensity };
}
if (isHorizontalArm(upper) && isTerminalToward(terminal, 90) && rightWrist.y > rightElbow.y + ARM_PATTERN_MIN_VERTICAL_REACH) {
return { gesture: "rotate_down", confidence: 0.8, intensity };
}
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 =
leftWrist.x < leftElbow.x - ARM_PATTERN_MIN_SIDE_REACH * 0.25 &&
leftWrist.x < leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.55 &&
rightWrist.x > rightElbow.x + ARM_PATTERN_MIN_SIDE_REACH * 0.25 &&
rightWrist.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.55;
const bothElbowsParticipating =
leftElbow.x <= leftShoulder.x + ARM_PATTERN_MIN_SIDE_REACH &&
rightElbow.x >= rightShoulder.x - ARM_PATTERN_MIN_SIDE_REACH;
const handsNearCenter =
leftElbow.x < leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.5 &&
rightElbow.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.5 &&
leftWrist.x > leftElbow.x &&
rightWrist.x < rightElbow.x &&
wristsApart < shoulderWidth * ZOOM_CLOSE_WRIST_SPREAD_FACTOR;
return (
(bothHandsOutside && bothElbowsParticipating && wristsApart > shoulderWidth * ZOOM_SUPPRESS_WRIST_SPREAD_FACTOR) ||
handsNearCenter
);
}
function applyPoseLatch(observation, state) {
if (!state || !observation) return observation;
if (state.activePatternGesture === observation.gesture) return null;
state.activePatternGesture = observation.gesture;
return observation;
}
function recognizeGesture(joints, previousJoints, options = {}) {
const state = options.state || null;
const leftEar = getJoint(joints, "left_ear");
const rightEar = getJoint(joints, "right_ear");
const leftWrist = getJoint(joints, "left_wrist");
const rightWrist = getJoint(joints, "right_wrist");
const leftElbow = getJoint(joints, "left_elbow");
const rightElbow = getJoint(joints, "right_elbow");
const leftShoulder = getJoint(joints, "left_shoulder");
const rightShoulder = getJoint(joints, "right_shoulder");
const previousLeftWrist = getJoint(previousJoints, "left_wrist");
if (!leftWrist || !rightWrist || !leftElbow || !rightElbow || !leftShoulder || !rightShoulder) return null;
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;
const leftDeltaX = previousLeftWrist ? leftWrist.x - previousLeftWrist.x : 0;
const leftDeltaY = previousLeftWrist ? leftWrist.y - previousLeftWrist.y : 0;
const headTiltY = leftEar && rightEar ? rightEar.y - leftEar.y : 0;
if (!rightRaised && leftRaised && leftDeltaY < -LEFT_WRIST_LAYER_DELTA_Y) {
return { gesture: "layer_prev", confidence: 0.78, intensity: Math.min(1, Math.abs(leftDeltaY) * WRIST_LAYER_INTENSITY_SCALE) };
}
if (!rightRaised && leftRaised && leftDeltaY > LEFT_WRIST_LAYER_DELTA_Y) {
return { gesture: "layer_next", confidence: 0.78, intensity: Math.min(1, Math.abs(leftDeltaY) * WRIST_LAYER_INTENSITY_SCALE) };
}
if (headTiltY < -HEAD_TILT_DELTA_Y) {
return { gesture: "focus_prev", confidence: 0.78, intensity: Math.min(1, Math.abs(headTiltY) * HEAD_TILT_INTENSITY_SCALE) };
}
if (headTiltY > HEAD_TILT_DELTA_Y) {
return { gesture: "focus_next", confidence: 0.78, intensity: Math.min(1, Math.abs(headTiltY) * HEAD_TILT_INTENSITY_SCALE) };
}
const pattern =
getZoomPattern(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth) ||
(
isZoomCandidatePose(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth)
? null
: getRightArmPattern(rightShoulder, rightElbow, rightWrist)
);
if (pattern) return applyPoseLatch(pattern, state);
if (state) state.activePatternGesture = null;
return null;
}
async function createDefaultRecognizer() {
const { FilesetResolver, PoseLandmarker } = await importMediaPipeTasksVision();
const vision = await FilesetResolver.forVisionTasks(MEDIAPIPE_WASM_URL);
const pose = await PoseLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath: POSE_MODEL_URL,
delegate: "GPU",
},
runningMode: "VIDEO",
numPoses: 1,
});
return {
recognize(video, timestampMs) {
const result = pose.detectForVideo(video, timestampMs);
const landmarks = result?.landmarks?.[0] || [];
return normalizePoseLandmarks(landmarks);
},
close() {
pose.close?.();
},
};
}
async function importMediaPipeTasksVision() {
const failures = [];
for (const moduleUrl of MEDIAPIPE_TASKS_URLS) {
try {
const module = await import(moduleUrl);
if (module?.FilesetResolver && module?.PoseLandmarker) {
return module;
}
failures.push(`${moduleUrl}: missing MediaPipe exports`);
} catch (error) {
failures.push(`${moduleUrl}: ${error?.message || String(error)}`);
}
}
const error = new Error("无法加载 MediaPipe Tasks Vision 模块,请检查网络或切换 Motion Agent");
error.details = failures;
throw error;
}
export function createBrowserCameraProvider(options = {}) {
const {
mediaDevices = typeof navigator !== "undefined" ? navigator.mediaDevices : null,
recognizerFactory = createDefaultRecognizer,
requestAnimationFrameFn =
typeof requestAnimationFrame !== "undefined"
? requestAnimationFrame.bind(globalThis)
: (callback) => setTimeout(() => callback(nowMs()), 16),
cancelAnimationFrameFn =
typeof cancelAnimationFrame !== "undefined"
? cancelAnimationFrame.bind(globalThis)
: clearTimeout,
onMessage = () => {},
onState = () => {},
onStatus = () => {},
onVideoSource = () => {},
} = options;
let disposed = false;
let connected = false;
let stream = null;
let video = null;
let recognizer = null;
let rafId = null;
let lastFrameAt = 0;
let lastGestureAt = 0;
let seq = 0;
let previousJoints = [];
const gestureState = {};
function emitState(detail = {}) {
onState({
provider: "browser_camera",
connected,
...detail,
});
}
function emitStatus(message, type = "info", extra = {}) {
onStatus(message, type);
emitState({ message, ...extra });
}
function stopStream() {
onVideoSource({
provider: "browser_camera",
source: null,
active: false,
});
if (stream) {
stream.getTracks?.().forEach((track) => track.stop?.());
stream = null;
}
if (video) {
video.pause?.();
video.srcObject = null;
video.remove?.();
video = null;
}
}
function emitSkeleton(joints, matchedGesture = null, confidence = 0) {
onMessage({
type: "skeleton",
timestamp_ms: wallClockMs(),
source: "browser-camera",
mode: "single",
camera_id: "browser:getUserMedia",
matched_gesture: matchedGesture,
confidence,
joints,
bones: POSE_BONES,
});
}
function emitGesture(observation) {
seq += 1;
onMessage({
type: "gesture",
gesture: observation.gesture,
phase: "discrete",
confidence: observation.confidence,
intensity: observation.intensity,
timestamp_ms: wallClockMs(),
seq,
source: "browser-camera",
mode: "single",
payload: {},
});
}
function scheduleFrame() {
if (disposed) return;
rafId = requestAnimationFrameFn(processFrame);
}
function processFrame(timestamp) {
if (disposed || !video || !recognizer) return;
if (timestamp - lastFrameAt < FRAME_INTERVAL_MS) {
scheduleFrame();
return;
}
lastFrameAt = timestamp;
try {
const joints = recognizer.recognize(video, timestamp) || [];
const currentWallMs = wallClockMs();
const observation = recognizeGesture(joints, previousJoints, {
state: gestureState,
timestampMs: currentWallMs,
});
const canEmitGesture = observation && currentWallMs - lastGestureAt >= GESTURE_COOLDOWN_MS;
if (canEmitGesture) {
lastGestureAt = currentWallMs;
emitGesture(observation);
}
emitSkeleton(
joints,
observation?.gesture || null,
observation?.confidence || 0,
);
previousJoints = joints;
} catch (error) {
emitStatus(`浏览器动捕识别失败: ${error?.message || String(error)}`, "error", {
error: "recognition_failed",
});
}
scheduleFrame();
}
async function startCamera() {
if (!canUseBrowserCamera(mediaDevices)) {
emitStatus("当前浏览器不支持 getUserMedia 摄像头接口", "error", {
error: "get_user_media_unavailable",
});
return false;
}
if (!isSecureCameraContext()) {
emitStatus("浏览器摄像头需要 HTTPS 或 localhost 环境", "error", {
error: "insecure_context",
});
return false;
}
try {
recognizer = await recognizerFactory();
} catch (error) {
connected = false;
emitStatus(`浏览器动捕模型加载失败: ${error?.message || String(error)}`, "error", {
error: "model_load_failed",
});
return false;
}
try {
stream = await mediaDevices.getUserMedia(CAMERA_CONSTRAINTS);
video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.autoplay = true;
video.style.display = "none";
video.srcObject = stream;
document.body.appendChild(video);
await video.play();
await waitForVideoMetadata(video);
onVideoSource({
provider: "browser_camera",
source: video,
active: true,
});
} catch (error) {
connected = false;
recognizer?.close?.();
recognizer = null;
stopStream();
emitStatus(getUserMediaErrorMessage(error), "error", {
error: "browser_camera_failed",
});
return false;
}
connected = true;
emitStatus("浏览器摄像头动捕已连接", "info");
scheduleFrame();
return true;
}
return {
provider: "browser_camera",
async start() {
if (disposed) return false;
return startCamera();
},
stop() {
disposed = true;
if (rafId) {
cancelAnimationFrameFn(rafId);
rafId = null;
}
recognizer?.close?.();
recognizer = null;
stopStream();
connected = false;
emitState({ connected: false });
},
isConnected() {
return connected;
},
};
}
export {
CAMERA_CONSTRAINTS,
importMediaPipeTasksVision,
POSE_BONES,
recognizeGesture,
};

View File

@@ -0,0 +1,257 @@
import {
createMotionAgentProvider,
DEFAULT_AGENT_URL,
} from "./motion-agent-provider.js";
import { createBrowserCameraProvider } from "./motion-browser-provider.js";
import {
DEFAULT_MOTION_PROVIDER,
MOTION_PROVIDER_AGENT,
normalizeGestureMessage,
normalizeMotionProvider,
normalizeSkeletonMessage,
} from "./motion-protocol.js";
import {
MOTION_CONTROL_STATE_EVENT,
MOTION_DEBUG_FRAME_EVENT,
MOTION_DEBUG_VIDEO_SOURCE_EVENT,
MOTION_RECOGNITION_PAUSE_EVENT,
} from "./motion-events.js";
const DEFAULT_MIN_CONFIDENCE = 0.72;
const DEFAULT_COOLDOWN_MS = 120;
const DEFAULT_FOCUS_COOLDOWN_MS = 900;
const DEFAULT_LAYER_COOLDOWN_MS = 1400;
const DEFAULT_CONFIRM_COOLDOWN_MS = 1200;
const ENABLED_STORAGE_KEY = "planet-earth-motion-control-enabled";
const URL_STORAGE_KEY = "planet-earth-motion-control-url";
const GESTURE_POLICIES = {
rotate_left: { group: "rotate_left", cooldownMs: DEFAULT_COOLDOWN_MS },
rotate_right: { group: "rotate_right", cooldownMs: DEFAULT_COOLDOWN_MS },
rotate_up: { group: "rotate_up", cooldownMs: DEFAULT_COOLDOWN_MS },
rotate_down: { group: "rotate_down", cooldownMs: DEFAULT_COOLDOWN_MS },
zoom_in: { group: "zoom_in", cooldownMs: DEFAULT_COOLDOWN_MS },
zoom_out: { group: "zoom_out", cooldownMs: DEFAULT_COOLDOWN_MS },
focus_prev: { group: "focus", cooldownMs: DEFAULT_FOCUS_COOLDOWN_MS },
focus_next: { group: "focus", cooldownMs: DEFAULT_FOCUS_COOLDOWN_MS },
layer_prev: { group: "layer", cooldownMs: DEFAULT_LAYER_COOLDOWN_MS },
layer_next: { group: "layer", cooldownMs: DEFAULT_LAYER_COOLDOWN_MS },
confirm: { group: "confirm", cooldownMs: DEFAULT_CONFIRM_COOLDOWN_MS },
};
function getSearchParams() {
if (typeof window === "undefined") return new URLSearchParams();
return new URLSearchParams(window.location.search || "");
}
function dispatchWindowEvent(name, detail) {
if (typeof window === "undefined") return;
if (name === MOTION_DEBUG_VIDEO_SOURCE_EVENT) {
window.__earthMotionDebugVideoSource = detail;
}
window.dispatchEvent(new CustomEvent(name, { detail }));
}
function getConfiguredAgentUrl() {
if (typeof window === "undefined") return DEFAULT_AGENT_URL;
const search = getSearchParams();
const queryUrl = search.get("motionAgent");
if (queryUrl) return queryUrl;
const storedUrl = window.localStorage?.getItem(URL_STORAGE_KEY);
return storedUrl || DEFAULT_AGENT_URL;
}
export function getConfiguredMotionProvider(fallback = DEFAULT_MOTION_PROVIDER) {
const search = getSearchParams();
if (search.get("motionAgent")) return MOTION_PROVIDER_AGENT;
const queryProvider = search.get("motionProvider");
if (queryProvider) return normalizeMotionProvider(queryProvider, fallback);
return normalizeMotionProvider(fallback, DEFAULT_MOTION_PROVIDER);
}
export function shouldEnableMotionControl() {
if (typeof window === "undefined") return false;
const search = getSearchParams();
if (search.get("motion") === "1") return true;
if (search.get("motion") === "0") return false;
return window.localStorage?.getItem(ENABLED_STORAGE_KEY) === "true";
}
export function createMotionControlAdapter(options = {}) {
const {
enabled = false,
provider = DEFAULT_MOTION_PROVIDER,
url = getConfiguredAgentUrl(),
minConfidence = DEFAULT_MIN_CONFIDENCE,
cooldownMs = DEFAULT_COOLDOWN_MS,
providerFactories = {},
WebSocketCtor = typeof WebSocket !== "undefined" ? WebSocket : null,
onRotate = () => false,
onZoom = () => false,
onConfirm = () => false,
onFocus = () => false,
onLayer = () => false,
onSkeleton = () => {},
onStatus = () => {},
onVideoSource = (detail) => dispatchWindowEvent(MOTION_DEBUG_VIDEO_SOURCE_EVENT, detail),
nowFn = () => Date.now(),
} = options;
const selectedProvider = getConfiguredMotionProvider(provider);
let disposed = false;
let activeProvider = null;
let connected = false;
let recognitionPaused = false;
const lastHandledByGestureGroup = new Map();
function emitState(detail) {
connected = Boolean(detail?.connected);
dispatchWindowEvent(MOTION_CONTROL_STATE_EVENT, {
provider: selectedProvider,
connected,
recognitionPaused,
...detail,
});
}
function setRecognitionPaused(nextPaused) {
recognitionPaused = Boolean(nextPaused);
emitState({ provider: selectedProvider, connected });
}
function shouldHandleGesture(event) {
if (!event || event.confidence < minConfidence) return false;
const policy = GESTURE_POLICIES[event.gesture] || {
group: event.gesture,
cooldownMs,
};
const effectiveCooldownMs =
policy.cooldownMs === DEFAULT_COOLDOWN_MS ? cooldownMs : policy.cooldownMs;
const now = nowFn();
const last = lastHandledByGestureGroup.get(policy.group);
if (last !== undefined && now - last < effectiveCooldownMs) return false;
lastHandledByGestureGroup.set(policy.group, now);
return true;
}
function handleGesture(event) {
if (recognitionPaused) return;
if (!shouldHandleGesture(event)) return;
if (event.gesture === "rotate_left" || event.gesture === "rotate_right") {
onRotate("horizontal", event.gesture === "rotate_left" ? "left" : "right", event.intensity, event);
} else if (event.gesture === "rotate_up" || event.gesture === "rotate_down") {
onRotate("vertical", event.gesture === "rotate_up" ? "up" : "down", event.intensity, event);
} else if (event.gesture === "zoom_in") {
onZoom("in", event.intensity, event);
} else if (event.gesture === "zoom_out") {
onZoom("out", event.intensity, event);
} else if (event.gesture === "focus_prev") {
onFocus("prev", event);
} else if (event.gesture === "focus_next") {
onFocus("next", event);
} else if (event.gesture === "layer_prev") {
onLayer("prev", event);
} else if (event.gesture === "layer_next") {
onLayer("next", event);
} else if (event.gesture === "confirm") {
onConfirm(event);
}
}
function handleProviderMessage(rawMessage) {
let data = rawMessage;
if (typeof rawMessage === "string") {
try {
data = JSON.parse(rawMessage);
} catch (_error) {
return;
}
}
const gesture = normalizeGestureMessage(data, selectedProvider);
if (gesture) {
handleGesture(gesture);
return;
}
const skeleton = normalizeSkeletonMessage(data, selectedProvider);
if (skeleton) {
const nextSkeleton = recognitionPaused
? { ...skeleton, matchedGesture: null, confidence: 0 }
: skeleton;
onSkeleton(nextSkeleton);
dispatchWindowEvent(MOTION_DEBUG_FRAME_EVENT, nextSkeleton);
return;
}
if (data?.type === "status" || data?.type === "heartbeat") {
emitState({ provider: selectedProvider, connected, message: data });
}
}
function createProvider() {
const sharedOptions = {
onMessage: handleProviderMessage,
onState: emitState,
onStatus,
onVideoSource,
};
if (providerFactories[selectedProvider]) {
return providerFactories[selectedProvider]({
...sharedOptions,
url,
WebSocketCtor,
});
}
if (selectedProvider === MOTION_PROVIDER_AGENT) {
return createMotionAgentProvider({
...sharedOptions,
url,
WebSocketCtor,
});
}
return createBrowserCameraProvider(sharedOptions);
}
return {
start() {
if (!enabled || disposed) return false;
if (typeof window !== "undefined") {
window.addEventListener(MOTION_RECOGNITION_PAUSE_EVENT, handleRecognitionPause);
}
activeProvider = createProvider();
const result = activeProvider.start();
emitState({ provider: selectedProvider, connected: activeProvider.isConnected?.() || false });
return result;
},
stop() {
disposed = true;
if (typeof window !== "undefined") {
window.removeEventListener(MOTION_RECOGNITION_PAUSE_EVENT, handleRecognitionPause);
}
activeProvider?.stop?.();
activeProvider = null;
connected = false;
emitState({ provider: selectedProvider, connected: false });
},
isConnected() {
return Boolean(activeProvider?.isConnected?.());
},
getProvider() {
return selectedProvider;
},
handleMessage: handleProviderMessage,
};
function handleRecognitionPause(event) {
setRecognitionPaused(event?.detail?.paused === true);
}
}
export {
DEFAULT_AGENT_URL,
DEFAULT_MOTION_PROVIDER,
normalizeGestureMessage,
normalizeSkeletonMessage,
normalizeMotionProvider,
};

View File

@@ -0,0 +1,624 @@
import { describe, expect, test } from "bun:test";
import {
createMotionControlAdapter,
normalizeMotionProvider,
} from "./motion-control.js";
import {
createBrowserCameraProvider,
recognizeGesture,
} from "./motion-browser-provider.js";
class TestEventTarget {
constructor() {
this.events = [];
this.listeners = new Map();
}
dispatchEvent(event) {
this.events.push(event);
(this.listeners.get(event.type) || []).forEach((listener) => listener(event));
return true;
}
addEventListener(type, listener) {
const listeners = this.listeners.get(type) || [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type, listener) {
const listeners = this.listeners.get(type) || [];
this.listeners.set(type, listeners.filter((item) => item !== listener));
}
}
function installWindow(search = "") {
const target = new TestEventTarget();
globalThis.CustomEvent = class CustomEvent {
constructor(type, options = {}) {
this.type = type;
this.detail = options.detail;
}
};
globalThis.window = {
location: {
search,
hostname: "localhost",
},
isSecureContext: true,
localStorage: {
getItem() {
return null;
},
},
dispatchEvent: target.dispatchEvent.bind(target),
addEventListener: target.addEventListener.bind(target),
removeEventListener: target.removeEventListener.bind(target),
};
return target;
}
function installDocument() {
const tracks = [];
globalThis.document = {
body: {
appendChild() {},
},
createElement(tagName) {
expect(tagName).toBe("video");
return {
muted: false,
playsInline: false,
autoplay: false,
readyState: 4,
style: {},
srcObject: null,
videoWidth: 640,
videoHeight: 360,
addEventListener() {},
play: () => Promise.resolve(),
pause() {},
remove() {},
};
},
};
return tracks;
}
describe("motion-control provider manager", () => {
test("normalizes browser and agent provider aliases", () => {
expect(normalizeMotionProvider("browser")).toBe("browser_camera");
expect(normalizeMotionProvider("agent")).toBe("motion_agent");
expect(normalizeMotionProvider("motion_agent")).toBe("motion_agent");
expect(normalizeMotionProvider("unknown")).toBe("browser_camera");
});
test("mock skeleton message dispatches debug frame event", () => {
const events = installWindow();
const adapter = createMotionControlAdapter({
enabled: true,
providerFactories: {
browser_camera: ({ onMessage, onState }) => ({
start() {
onState({ connected: true });
onMessage({
type: "skeleton",
timestamp_ms: 1000,
source: "test",
joints: [{ id: "left_wrist", x: 0.2, y: 0.3, confidence: 1 }],
bones: [],
});
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
const debugEvent = events.events.find((event) => event.type === "earth:motion-debug-frame");
expect(debugEvent?.detail.joints[0]).toEqual({
id: "left_wrist",
x: 0.2,
y: 0.3,
confidence: 1,
});
});
test("mock gesture message maps to Earth control callback", () => {
installWindow();
const rotations = [];
const adapter = createMotionControlAdapter({
enabled: true,
onRotate: (...args) => rotations.push(args),
providerFactories: {
browser_camera: ({ onMessage }) => ({
start() {
onMessage({
type: "gesture",
gesture: "rotate_left",
confidence: 0.91,
intensity: 0.5,
});
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
expect(rotations[0][0]).toBe("horizontal");
expect(rotations[0][1]).toBe("left");
expect(rotations[0][2]).toBe(0.5);
});
test("recognition pause suppresses gestures and matched skeleton state", () => {
const events = installWindow();
const rotations = [];
const adapter = createMotionControlAdapter({
enabled: true,
cooldownMs: 0,
onRotate: (...args) => rotations.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
window.dispatchEvent(new CustomEvent("earth:motion-recognition-pause", {
detail: { paused: true },
}));
adapter.handleMessage({
type: "gesture",
gesture: "rotate_left",
confidence: 0.91,
intensity: 0.5,
});
adapter.handleMessage({
type: "skeleton",
matched_gesture: "rotate_left",
confidence: 0.91,
joints: [],
bones: [],
});
const debugEvent = events.events.findLast?.((event) => event.type === "earth:motion-debug-frame") ||
events.events.filter((event) => event.type === "earth:motion-debug-frame").at(-1);
expect(rotations).toHaveLength(0);
expect(debugEvent?.detail.matchedGesture).toBeNull();
expect(debugEvent?.detail.confidence).toBe(0);
});
test("mock vertical gesture and focus gesture use dedicated callbacks", () => {
installWindow();
const rotations = [];
const focuses = [];
const layers = [];
const adapter = createMotionControlAdapter({
enabled: true,
cooldownMs: 0,
onRotate: (...args) => rotations.push(args),
onFocus: (...args) => focuses.push(args),
onLayer: (...args) => layers.push(args),
providerFactories: {
browser_camera: ({ onMessage }) => ({
start() {
onMessage({
type: "gesture",
gesture: "rotate_up",
confidence: 0.91,
intensity: 0.6,
});
onMessage({
type: "gesture",
gesture: "focus_next",
confidence: 0.91,
intensity: 0.8,
});
onMessage({
type: "gesture",
gesture: "layer_next",
confidence: 0.91,
intensity: 0.8,
});
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
expect(rotations[0][0]).toBe("vertical");
expect(rotations[0][1]).toBe("up");
expect(rotations[0][2]).toBe(0.6);
expect(focuses[0][0]).toBe("next");
expect(layers[0][0]).toBe("next");
});
test("continuous rotate can retrigger after the short cooldown", () => {
installWindow();
let now = 1000;
const rotations = [];
const adapter = createMotionControlAdapter({
enabled: true,
nowFn: () => now,
onRotate: (...args) => rotations.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
adapter.handleMessage({ type: "gesture", gesture: "rotate_right", confidence: 0.91 });
now += 60;
adapter.handleMessage({ type: "gesture", gesture: "rotate_right", confidence: 0.91 });
now += 70;
adapter.handleMessage({ type: "gesture", gesture: "rotate_right", confidence: 0.91 });
expect(rotations).toHaveLength(2);
});
test("focus gestures share one 900ms cooldown group", () => {
installWindow();
let now = 1000;
const focuses = [];
const adapter = createMotionControlAdapter({
enabled: true,
nowFn: () => now,
onFocus: (...args) => focuses.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
adapter.handleMessage({ type: "gesture", gesture: "focus_next", confidence: 0.91 });
now += 100;
adapter.handleMessage({ type: "gesture", gesture: "focus_prev", confidence: 0.91 });
now += 900;
adapter.handleMessage({ type: "gesture", gesture: "focus_prev", confidence: 0.91 });
expect(focuses.map((args) => args[0])).toEqual(["next", "prev"]);
});
test("layer gestures share one 1400ms cooldown group", () => {
installWindow();
let now = 1000;
const layers = [];
const adapter = createMotionControlAdapter({
enabled: true,
nowFn: () => now,
onLayer: (...args) => layers.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
adapter.handleMessage({ type: "gesture", gesture: "layer_next", confidence: 0.91 });
now += 1200;
adapter.handleMessage({ type: "gesture", gesture: "layer_next", confidence: 0.91 });
now += 200;
adapter.handleMessage({ type: "gesture", gesture: "layer_prev", confidence: 0.91 });
expect(layers.map((args) => args[0])).toEqual(["next", "prev"]);
});
test("confirm uses a 1200ms cooldown", () => {
installWindow();
let now = 1000;
const confirms = [];
const adapter = createMotionControlAdapter({
enabled: true,
nowFn: () => now,
onConfirm: (...args) => confirms.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
adapter.handleMessage({ type: "gesture", gesture: "confirm", confidence: 0.91 });
now += 1000;
adapter.handleMessage({ type: "gesture", gesture: "confirm", confidence: 0.91 });
now += 200;
adapter.handleMessage({ type: "gesture", gesture: "confirm", confidence: 0.91 });
expect(confirms).toHaveLength(2);
});
});
function createPoseJoints(overrides = {}) {
const base = {
left_ear: { id: "left_ear", x: 0.45, y: 0.2, confidence: 1 },
right_ear: { id: "right_ear", x: 0.55, y: 0.2, confidence: 1 },
left_shoulder: { id: "left_shoulder", x: 0.42, y: 0.5, confidence: 1 },
right_shoulder: { id: "right_shoulder", x: 0.58, y: 0.5, confidence: 1 },
left_elbow: { id: "left_elbow", x: 0.4, y: 0.62, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.6, y: 0.62, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.42, y: 0.65, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.58, y: 0.65, confidence: 1 },
};
return Object.values({
...base,
...overrides,
});
}
describe("browser camera gesture semantics", () => {
test("right-arm left-facing pattern maps to rotate_right", () => {
const current = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.5, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.4, y: 0.53, confidence: 1 },
});
expect(recognizeGesture(current, current)?.gesture).toBe("rotate_right");
});
test("right-arm upward terminal pattern maps to rotate_up", () => {
const current = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.68, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.71, y: 0.39, confidence: 1 },
});
expect(recognizeGesture(current, current)?.gesture).toBe("rotate_up");
});
test("right-arm terminal can float about 30 degrees while matching horizontal wave", () => {
const current = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.5, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.39, y: 0.56, confidence: 1 },
});
expect(recognizeGesture(current, current)?.gesture).toBe("rotate_right");
});
test("left-hand vertical movement switches motion layer", () => {
const previous = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.42, y: 0.48, confidence: 1 },
});
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.42, y: 0.38, confidence: 1 },
});
expect(recognizeGesture(current, previous)?.gesture).toBe("layer_prev");
});
test("centered close hands no longer trigger confirm", () => {
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.48, y: 0.6, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.52, y: 0.6, confidence: 1 },
});
expect(recognizeGesture(current, current)).toBeNull();
});
test("head tilt emits focus navigation gesture", () => {
const current = createPoseJoints({
left_ear: { id: "left_ear", x: 0.45, y: 0.24, confidence: 1 },
right_ear: { id: "right_ear", x: 0.55, y: 0.18, confidence: 1 },
});
expect(recognizeGesture(current, current)?.gesture).toBe("focus_prev");
});
test("holding right hand high does not continuously rotate", () => {
const current = createPoseJoints({
right_wrist: { id: "right_wrist", x: 0.58, y: 0.34, confidence: 1 },
});
expect(recognizeGesture(current, current)).toBeNull();
});
test("holding the same pattern only emits once until neutral", () => {
const state = {};
const pattern = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.5, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.4, y: 0.5, confidence: 1 },
});
const neutral = createPoseJoints();
expect(recognizeGesture(pattern, neutral, { state })?.gesture).toBe("rotate_right");
expect(recognizeGesture(pattern, pattern, { state })).toBeNull();
expect(recognizeGesture(neutral, pattern, { state })).toBeNull();
expect(recognizeGesture(pattern, neutral, { state })?.gesture).toBe("rotate_right");
});
test("hands resting below the shoulders do not rotate down", () => {
const current = createPoseJoints({
right_wrist: { id: "right_wrist", x: 0.58, y: 0.82, confidence: 1 },
});
expect(recognizeGesture(current, current)).toBeNull();
});
test("moving left hand near the chest does not trigger zoom out", () => {
const previous = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.38, y: 0.62, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.55, y: 0.62, confidence: 1 },
});
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.49, y: 0.62, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.55, y: 0.62, confidence: 1 },
});
expect(recognizeGesture(current, previous)).toBeNull();
});
test("two hands opening from center trigger zoom in", () => {
const current = 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(current, current)?.gesture).toBe("zoom_in");
});
test("two hands opening with tilted wrists still trigger zoom in", () => {
const current = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.37, y: 0.51, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.31, y: 0.46, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.63, y: 0.51, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.69, y: 0.58, confidence: 1 },
});
expect(recognizeGesture(current, current)?.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 },
left_wrist: { id: "left_wrist", x: 0.35, y: 0.58, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.64, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.7, y: 0.39, confidence: 1 },
});
expect(recognizeGesture(current, current)).toBeNull();
});
test("two hands closing toward center trigger zoom out", () => {
const current = 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(current, current)?.gesture).toBe("zoom_out");
});
});
describe("browser camera provider", () => {
test("starts with mocked getUserMedia and emits active state", async () => {
installWindow();
installDocument();
const stopped = [];
const states = [];
const videoSources = [];
const provider = createBrowserCameraProvider({
mediaDevices: {
getUserMedia: () =>
Promise.resolve({
getTracks: () => [{ stop: () => stopped.push("camera") }],
}),
},
recognizerFactory: () =>
Promise.resolve({
recognize: () => [],
close() {},
}),
requestAnimationFrameFn: () => 0,
onState: (state) => states.push(state),
onVideoSource: (source) => videoSources.push(source),
});
await provider.start();
provider.stop();
expect(states.some((state) => state.connected === true)).toBe(true);
expect(stopped).toEqual(["camera"]);
expect(videoSources.some((source) => source.active === true)).toBe(true);
expect(videoSources.at(-1)).toEqual({
provider: "browser_camera",
source: null,
active: false,
});
});
test("reports permission errors without falling back to dry-run", async () => {
installWindow();
installDocument();
const statuses = [];
const closed = [];
const provider = createBrowserCameraProvider({
mediaDevices: {
getUserMedia: () =>
Promise.reject(Object.assign(new Error("denied"), { name: "NotAllowedError" })),
},
recognizerFactory: () =>
Promise.resolve({
recognize: () => [],
close: () => closed.push("recognizer"),
}),
onStatus: (message, type) => statuses.push({ message, type }),
});
const started = await provider.start();
expect(started).toBe(false);
expect(statuses[0]).toEqual({
message: "浏览器摄像头权限被拒绝",
type: "error",
});
expect(closed).toEqual(["recognizer"]);
});
test("reports model load errors separately from camera permission", async () => {
installWindow();
installDocument();
const statuses = [];
const requested = [];
const provider = createBrowserCameraProvider({
mediaDevices: {
getUserMedia: () => {
requested.push("camera");
return Promise.resolve({
getTracks: () => [],
});
},
},
recognizerFactory: () => Promise.reject(new Error("model unavailable")),
onStatus: (message, type) => statuses.push({ message, type }),
});
const started = await provider.start();
expect(started).toBe(false);
expect(requested).toEqual([]);
expect(statuses[0]).toEqual({
message: "浏览器动捕模型加载失败: model unavailable",
type: "error",
});
});
});

View File

@@ -0,0 +1,186 @@
import { CONNECTOR_CONFIG, CRUISE_CONFIG } from "./constants.js";
const MOTION_CONNECTOR_READY_TIMEOUT_MS = 1200;
const MOTION_CONNECTOR_DRAW_MS = 420;
const MOTION_PRESENTATION_HIDE_MS = 220;
const MOTION_CARD_ESTIMATED_WIDTH_PX = 300;
const MOTION_CARD_ESTIMATED_HEIGHT_PX = 420;
const MOTION_CARD_SCREEN_MARGIN_PX = 12;
const MOTION_MOBILE_POPUP_ESTIMATED_WIDTH_PX = 220;
const MOTION_MOBILE_POPUP_ESTIMATED_HEIGHT_PX = 68;
const MOTION_MOBILE_POPUP_TOP_RATIO = 0.17;
const MOTION_MOBILE_POPUP_MARGIN_PX = 14;
function getMotionCardScreenCoords() {
if (document.body.classList.contains("layout-mode-mobile")) {
const estimatedWidth = Math.min(
MOTION_MOBILE_POPUP_ESTIMATED_WIDTH_PX,
window.innerWidth - MOTION_MOBILE_POPUP_MARGIN_PX * 2,
);
const safeBottom =
Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom")) || 0;
const y = Math.max(
MOTION_MOBILE_POPUP_MARGIN_PX,
Math.min(
window.innerHeight * MOTION_MOBILE_POPUP_TOP_RATIO,
window.innerHeight -
safeBottom -
MOTION_MOBILE_POPUP_ESTIMATED_HEIGHT_PX -
MOTION_MOBILE_POPUP_MARGIN_PX,
),
);
return {
x: Math.max(MOTION_MOBILE_POPUP_MARGIN_PX, window.innerWidth - estimatedWidth - MOTION_MOBILE_POPUP_MARGIN_PX),
y,
width: estimatedWidth,
height: MOTION_MOBILE_POPUP_ESTIMATED_HEIGHT_PX,
dockSide: "left",
};
}
const hudScale =
Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--hud-scale")) || 1;
const width = Math.min(MOTION_CARD_ESTIMATED_WIDTH_PX * hudScale, window.innerWidth - 32);
const height = Math.min(MOTION_CARD_ESTIMATED_HEIGHT_PX * hudScale, window.innerHeight * 0.7);
const x = window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - width * 0.5;
const y = window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - height * 0.5;
return {
x: Math.min(Math.max(MOTION_CARD_SCREEN_MARGIN_PX, x), window.innerWidth - width - MOTION_CARD_SCREEN_MARGIN_PX),
y: Math.min(Math.max(MOTION_CARD_SCREEN_MARGIN_PX, y), window.innerHeight - height - MOTION_CARD_SCREEN_MARGIN_PX),
width,
height,
};
}
function getMotionInfoOptions({ reveal = true } = {}) {
const placement = getMotionCardScreenCoords();
return {
x: placement.x,
y: placement.y,
absolute: true,
reveal,
anchorStable: true,
dockSide: placement.dockSide,
};
}
export function createMotionCruiseAdapter({
presentationController,
focusView,
getItems = () => [],
getItemId = (item) => item?.id || null,
resolveLatestItem = (item) => item,
getCandidateAnchor,
getCandidateFocusCoords,
showCandidateInfo,
hideInfo,
clearCandidateVisual,
}) {
let currentItemId = null;
function getCurrentItem() {
if (!currentItemId) return null;
return getItems().find((item) => getItemId(item) === currentItemId) || null;
}
function getCandidate(item) {
return item?.payload || item || null;
}
function getCandidateId(candidate) {
return (
candidate?.id ||
candidate?.object?.userData?.id ||
candidate?.object?.userData?.collector ||
candidate?.object?.userData?.mmsi ||
candidate?.object?.userData?.name ||
(Number.isInteger(candidate?.index) ? `satellite:${candidate.index}` : null)
);
}
function getVisibleCardTarget() {
const mobileTarget = document.getElementById("earth-mobile-popup");
const visibleMobileTarget =
mobileTarget instanceof HTMLElement && !mobileTarget.hasAttribute("hidden")
? mobileTarget
: null;
if (visibleMobileTarget) return visibleMobileTarget;
const infoPanel = document.getElementById("info-panel");
return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")
? infoPanel
: null;
}
return {
getSortedItems() {
return getItems();
},
getCurrentItem,
clearCurrentHighlight() {
const candidate = getCandidate(getCurrentItem());
currentItemId = null;
clearCandidateVisual?.(candidate);
},
async focusItem(item, { interrupt = false } = {}) {
const candidate = getCandidate(item);
currentItemId = getItemId(item) || getCandidateId(candidate);
const coords = getCandidateFocusCoords?.(candidate);
if (!coords) return;
await focusView({
lat: coords.lat,
lon: coords.lon,
rotLon: coords.lon - 270,
zoom: Math.max(coords.zoom || 1.08, 1.08),
duration: interrupt
? Math.round(CRUISE_CONFIG.focusDurationMs * 0.58)
: CRUISE_CONFIG.focusDurationMs,
suppressStatus: true,
});
},
async presentItem(item, { context }) {
const latestItem = resolveLatestItem(item) || item;
const candidate = getCandidate(latestItem);
if (!candidate) return false;
return presentationController.present(
{
id: getItemId(latestItem) || getCandidateId(candidate),
owner: "motion",
card: {
render: ({ reveal = true } = {}) =>
showCandidateInfo(candidate, getMotionInfoOptions({ reveal })),
hide: () => hideInfo?.(),
},
connector: {
enabled: true,
sourceProvider: () => getCandidateAnchor?.(getCandidate(resolveLatestItem(latestItem) || latestItem)),
targetProvider: getVisibleCardTarget,
options: {
routingMode: "adaptive",
sourceGapPx: 0,
targetGapPx: CONNECTOR_CONFIG.panelGapPx,
obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx,
},
readyTimeoutMs: MOTION_CONNECTOR_READY_TIMEOUT_MS,
drawMs: MOTION_CONNECTOR_DRAW_MS,
},
lifetime: { mode: "persistent" },
},
{ context },
);
},
async hidePresentation({ context }) {
presentationController.dismiss("sequenced_hide");
const hidden = context?.wait
? await context.wait(MOTION_PRESENTATION_HIDE_MS, { secondary: true })
: true;
return hidden;
},
repositionConnector() {
presentationController.update();
},
resetPresentation() {
presentationController.dismiss("owner_stop");
},
};
}

View File

@@ -0,0 +1,355 @@
import {
MOTION_CONTROL_STATE_EVENT,
MOTION_DEBUG_CLOSE_EVENT,
MOTION_DEBUG_FRAME_EVENT,
MOTION_DEBUG_VIDEO_SOURCE_EVENT,
MOTION_RECOGNITION_PAUSE_EVENT,
} from "./motion-events.js";
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 MOBILE_MOUNT_ID = "mobile-motion-debug-mount";
const FALLBACK_CANVAS_WIDTH = 320;
const FALLBACK_CANVAS_HEIGHT = 220;
const MIN_MEASURED_CANVAS_SIZE = 20;
const CANVAS_ASPECT_HEIGHT = 11;
const CANVAS_ASPECT_WIDTH = 16;
const CANVAS_CSS_HEIGHT = "calc(194px * var(--hud-scale))";
const CANVAS_CSS_MIN_HEIGHT = "calc(170px * var(--hud-scale))";
let panel = null;
let canvas = null;
let ctx = null;
let statusEl = null;
let matchEl = null;
let desktopParent = null;
let desktopNextSibling = null;
let visible = false;
let lastFrame = null;
let connected = false;
let provider = "browser_camera";
let videoSource = null;
let previewRafId = null;
let skeletonOnly = false;
let recognitionPaused = false;
let controlsBound = false;
function getProviderLabel(value) {
return value === "motion_agent" ? "Motion Agent" : "浏览器摄像头";
}
function createCanvasElement() {
const nextCanvas = document.createElement("canvas");
nextCanvas.id = DEBUG_CANVAS_ID;
nextCanvas.className = "motion-debug-canvas";
nextCanvas.width = FALLBACK_CANVAS_WIDTH;
nextCanvas.height = FALLBACK_CANVAS_HEIGHT;
return nextCanvas;
}
function getPanelElements() {
panel = panel || document.getElementById(DEBUG_PANEL_ID);
if (panel && !desktopParent) {
desktopParent = panel.parentElement;
desktopNextSibling = panel.nextSibling;
}
if (panel && !document.getElementById(DEBUG_CANVAS_ID)) {
const body = panel.querySelector(".motion-debug-body");
const footer = panel.querySelector(".motion-debug-footer");
if (body instanceof HTMLElement) {
body.insertBefore(createCanvasElement(), footer || null);
}
}
canvas = canvas || document.getElementById(DEBUG_CANVAS_ID);
ctx = ctx || canvas?.getContext?.("2d") || null;
statusEl = statusEl || document.getElementById(DEBUG_STATUS_ID);
matchEl = matchEl || document.getElementById(DEBUG_MATCH_ID);
}
function dispatchWindowEvent(name, detail = {}) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(name, { detail }));
}
function bindPanelControls() {
if (controlsBound || !(panel instanceof HTMLElement)) return;
controlsBound = true;
const closeButton = panel.querySelector(DEBUG_CLOSE_SELECTOR);
closeButton?.addEventListener?.("click", (event) => {
event.preventDefault();
event.stopPropagation();
dispatchWindowEvent(MOTION_DEBUG_CLOSE_EVENT);
});
const pauseInput = document.getElementById(DEBUG_PAUSE_ID);
pauseInput?.addEventListener?.("change", () => {
recognitionPaused = pauseInput.checked === true;
dispatchWindowEvent(MOTION_RECOGNITION_PAUSE_EVENT, { paused: recognitionPaused });
render();
});
}
function setText(element, value) {
if (element instanceof HTMLElement) {
element.textContent = value;
}
}
function ensurePanelLayout() {
if (canvas instanceof HTMLCanvasElement) {
canvas.style.display = "block";
canvas.style.width = "100%";
canvas.style.height = CANVAS_CSS_HEIGHT;
canvas.style.minHeight = CANVAS_CSS_MIN_HEIGHT;
}
const body = canvas?.closest?.(".motion-debug-body");
if (body instanceof HTMLElement) {
body.style.display = "flex";
body.style.flexDirection = "column";
}
}
function shouldUseMobileMount() {
return Boolean(document.querySelector(".layout-mode-mobile"));
}
function syncPanelMount() {
if (!(panel instanceof HTMLElement)) return;
const mobileMount = document.getElementById(MOBILE_MOUNT_ID);
if (shouldUseMobileMount() && mobileMount instanceof HTMLElement) {
if (panel.parentElement !== mobileMount) {
mobileMount.appendChild(panel);
}
return;
}
if (desktopParent && panel.parentElement !== desktopParent) {
desktopParent.insertBefore(panel, desktopNextSibling);
}
}
function resizeCanvasToDisplaySize() {
if (!(canvas instanceof HTMLCanvasElement)) return;
ensurePanelLayout();
const rect = canvas.getBoundingClientRect();
const scale = window.devicePixelRatio || 1;
const fallbackWidth = canvas.parentElement?.clientWidth || FALLBACK_CANVAS_WIDTH;
const cssWidth = rect.width >= MIN_MEASURED_CANVAS_SIZE ? rect.width : fallbackWidth;
const cssHeight = rect.height >= MIN_MEASURED_CANVAS_SIZE
? rect.height
: Math.max(
FALLBACK_CANVAS_HEIGHT,
Math.round(cssWidth * CANVAS_ASPECT_HEIGHT / CANVAS_ASPECT_WIDTH),
);
const width = Math.max(1, Math.round(cssWidth * scale));
const height = Math.max(1, Math.round(cssHeight * scale));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
function clearCanvas(message = "等待动捕数据") {
if (!ctx || !(canvas instanceof HTMLCanvasElement)) return;
resizeCanvasToDisplaySize();
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "rgba(8, 12, 20, 0.82)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "rgba(226, 232, 240, 0.74)";
ctx.font = `${Math.max(13, Math.round(canvas.width * 0.038))}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(message, canvas.width / 2, canvas.height / 2);
}
function hasDrawableVideoSource() {
return Boolean(
videoSource &&
typeof videoSource.videoWidth === "number" &&
typeof videoSource.videoHeight === "number" &&
videoSource.videoWidth > 0 &&
videoSource.videoHeight > 0 &&
videoSource.readyState >= 2,
);
}
function hasVideoSource() {
return Boolean(videoSource);
}
function drawVideoSource() {
if (!ctx || !(canvas instanceof HTMLCanvasElement) || !hasDrawableVideoSource()) return false;
const sourceRatio = videoSource.videoWidth / videoSource.videoHeight;
const canvasRatio = canvas.width / canvas.height;
let sourceWidth = videoSource.videoWidth;
let sourceHeight = videoSource.videoHeight;
let sourceX = 0;
let sourceY = 0;
if (sourceRatio > canvasRatio) {
sourceWidth = videoSource.videoHeight * canvasRatio;
sourceX = (videoSource.videoWidth - sourceWidth) / 2;
} else {
sourceHeight = videoSource.videoWidth / canvasRatio;
sourceY = (videoSource.videoHeight - sourceHeight) / 2;
}
ctx.drawImage(
videoSource,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
canvas.width,
canvas.height,
);
ctx.fillStyle = "rgba(4, 8, 14, 0.24)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
return true;
}
function drawFrame(frame = {}) {
if (!ctx || !(canvas instanceof HTMLCanvasElement)) return;
resizeCanvasToDisplaySize();
const matched = Boolean(frame?.matchedGesture);
const color = matched ? "#39e58c" : "#ff4d5f";
const jointMap = new Map((frame?.joints || []).map((joint) => [joint.id, joint]));
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (skeletonOnly || !drawVideoSource()) {
ctx.fillStyle = "rgba(8, 12, 20, 0.82)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.lineWidth = Math.max(2, canvas.width * 0.008);
ctx.lineCap = "round";
ctx.strokeStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = 10;
(frame?.bones || []).forEach(([fromId, toId]) => {
const from = jointMap.get(fromId);
const to = jointMap.get(toId);
if (!from || !to) return;
ctx.beginPath();
ctx.moveTo(from.x * canvas.width, from.y * canvas.height);
ctx.lineTo(to.x * canvas.width, to.y * canvas.height);
ctx.stroke();
});
ctx.shadowBlur = 6;
ctx.fillStyle = color;
(frame?.joints || []).forEach((joint) => {
ctx.beginPath();
ctx.arc(joint.x * canvas.width, joint.y * canvas.height, Math.max(4, canvas.width * 0.012), 0, Math.PI * 2);
ctx.fill();
});
ctx.shadowBlur = 0;
}
function stopPreviewLoop() {
if (previewRafId !== null) {
cancelAnimationFrame(previewRafId);
previewRafId = null;
}
}
function shouldAnimatePreview() {
return visible && connected && hasVideoSource();
}
function startPreviewLoop() {
if (previewRafId !== null || !shouldAnimatePreview()) return;
const tick = () => {
previewRafId = null;
if (!shouldAnimatePreview()) return;
drawFrame(lastFrame || {});
previewRafId = requestAnimationFrame(tick);
};
previewRafId = requestAnimationFrame(tick);
}
function render() {
getPanelElements();
if (!panel) return;
syncPanelMount();
ensurePanelLayout();
panel.classList.toggle("hud-panel-hidden", !visible);
panel.classList.toggle("is-motion-matched", Boolean(lastFrame?.matchedGesture));
panel.classList.toggle("is-motion-recognition-paused", recognitionPaused);
const providerLabel = getProviderLabel(provider);
const pauseSuffix = recognitionPaused ? " · 匹配已暂停" : "";
setText(statusEl, connected ? `${providerLabel}已连接${pauseSuffix}` : `${providerLabel}未连接${pauseSuffix}`);
if (!visible) {
stopPreviewLoop();
return;
}
if (lastFrame) {
const action = recognitionPaused ? "匹配已暂停" : (lastFrame.matchedGesture || "未匹配动作");
const confidence = !recognitionPaused && lastFrame.matchedGesture
? ` · ${Math.round((lastFrame.confidence || 0) * 100)}%`
: "";
setText(matchEl, `${action}${confidence}`);
drawFrame(lastFrame);
} else if (hasVideoSource()) {
setText(matchEl, "等待骨架数据");
drawFrame({});
} else {
setText(matchEl, "等待骨架数据");
clearCanvas(connected ? "等待动捕数据" : "动捕未连接");
}
startPreviewLoop();
}
export function initMotionDebugPanel() {
getPanelElements();
bindPanelControls();
const cachedVideoSource = window.__earthMotionDebugVideoSource;
if (cachedVideoSource?.active !== false && cachedVideoSource?.source) {
videoSource = cachedVideoSource.source;
provider = cachedVideoSource.provider || provider;
}
render();
window.addEventListener("resize", render);
window.addEventListener(MOTION_DEBUG_FRAME_EVENT, (event) => {
lastFrame = event.detail || null;
render();
});
window.addEventListener(MOTION_CONTROL_STATE_EVENT, (event) => {
connected = Boolean(event?.detail?.connected);
provider = event?.detail?.provider || provider;
if (!connected) {
videoSource = null;
stopPreviewLoop();
}
render();
});
window.addEventListener(MOTION_DEBUG_VIDEO_SOURCE_EVENT, (event) => {
if (event?.detail?.active === false) {
videoSource = null;
stopPreviewLoop();
} else {
videoSource = event?.detail?.source || null;
provider = event?.detail?.provider || provider;
videoSource?.addEventListener?.("loadedmetadata", render, { once: true });
videoSource?.addEventListener?.("canplay", render, { once: true });
}
render();
});
}
export function setMotionDebugPanelVisible(nextVisible) {
visible = Boolean(nextVisible);
render();
}
export function setMotionDebugPanelSkeletonOnly(nextSkeletonOnly) {
skeletonOnly = Boolean(nextSkeletonOnly);
render();
}

View File

@@ -0,0 +1,5 @@
export const MOTION_CONTROL_STATE_EVENT = "earth:motion-control-state";
export const MOTION_DEBUG_FRAME_EVENT = "earth:motion-debug-frame";
export const MOTION_DEBUG_VIDEO_SOURCE_EVENT = "earth:motion-debug-video-source";
export const MOTION_DEBUG_CLOSE_EVENT = "earth:motion-debug-close";
export const MOTION_RECOGNITION_PAUSE_EVENT = "earth:motion-recognition-pause";

View File

@@ -0,0 +1,93 @@
export const MOTION_PROVIDER_BROWSER = "browser_camera";
export const MOTION_PROVIDER_AGENT = "motion_agent";
export const DEFAULT_MOTION_PROVIDER = MOTION_PROVIDER_BROWSER;
export const MOTION_PROVIDERS = new Set([
MOTION_PROVIDER_BROWSER,
MOTION_PROVIDER_AGENT,
]);
export const MOTION_GESTURES = new Set([
"rotate_left",
"rotate_right",
"rotate_up",
"rotate_down",
"zoom_in",
"zoom_out",
"focus_prev",
"focus_next",
"layer_prev",
"layer_next",
"confirm",
]);
export function clamp01(value, fallback = 1) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.max(0, Math.min(1, number));
}
export function normalizeMotionProvider(value, fallback = DEFAULT_MOTION_PROVIDER) {
const normalized = String(value || "").trim();
if (normalized === "browser") return MOTION_PROVIDER_BROWSER;
if (normalized === "agent") return MOTION_PROVIDER_AGENT;
return MOTION_PROVIDERS.has(normalized) ? normalized : fallback;
}
export function normalizeGestureMessage(raw, fallbackSource = "motion-provider") {
if (!raw || typeof raw !== "object") return null;
if (raw.type !== "gesture") return null;
const gesture = String(raw.gesture || "").trim();
if (!MOTION_GESTURES.has(gesture)) return null;
return {
type: "gesture",
gesture,
phase: raw.phase || "discrete",
confidence: clamp01(raw.confidence, 0),
intensity: clamp01(raw.intensity, 1),
timestampMs: Number(raw.timestamp_ms || raw.timestampMs || Date.now()),
seq: Number(raw.seq || 0),
source: raw.source || fallbackSource,
mode: raw.mode || "single",
payload: raw.payload && typeof raw.payload === "object" ? raw.payload : {},
};
}
export function normalizeSkeletonMessage(raw, fallbackSource = "motion-provider") {
if (!raw || typeof raw !== "object" || raw.type !== "skeleton") return null;
const joints = Array.isArray(raw.joints)
? raw.joints
.map((joint) => {
const id = String(joint?.id || "").trim();
const x = Number(joint?.x);
const y = Number(joint?.y);
if (!id || !Number.isFinite(x) || !Number.isFinite(y)) return null;
return {
id,
x: clamp01(x, 0),
y: clamp01(y, 0),
confidence: clamp01(joint?.confidence, 1),
};
})
.filter(Boolean)
: [];
const bones = Array.isArray(raw.bones)
? raw.bones
.map((bone) => {
if (!Array.isArray(bone) || bone.length < 2) return null;
const from = String(bone[0] || "").trim();
const to = String(bone[1] || "").trim();
return from && to ? [from, to] : null;
})
.filter(Boolean)
: [];
return {
type: "skeleton",
timestampMs: Number(raw.timestamp_ms || raw.timestampMs || Date.now()),
source: raw.source || fallbackSource,
mode: raw.mode || "single",
cameraId: raw.camera_id || raw.cameraId || "unknown",
matchedGesture: raw.matched_gesture || raw.matchedGesture || null,
confidence: clamp01(raw.confidence, 0),
joints,
bones,
};
}

View File

@@ -0,0 +1,183 @@
import { createConnectorPath } from "./callout-connector.js";
const DEFAULT_CONNECTOR_READY_TIMEOUT_MS = 1200;
const DEFAULT_CONNECTOR_DRAW_MS = 420;
function nextAnimationFrame() {
return new Promise((resolve) => {
window.requestAnimationFrame(() => resolve());
});
}
function isCurrentContext(context) {
return !context || typeof context.isCurrent !== "function" || context.isCurrent();
}
function waitForContext(context, durationMs) {
if (context && typeof context.wait === "function") {
return context.wait(durationMs);
}
return new Promise((resolve) => {
window.setTimeout(() => resolve(isCurrentContext(context)), durationMs);
});
}
function getDefaultCardTarget() {
// TODO: replace the singleton card fallback with a presentation/card token check
// before BGP/News migrate here, so connectors only attach to their owning card.
const mobilePopup = document.getElementById("earth-mobile-popup");
if (mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden")) {
return mobilePopup;
}
const infoPanel = document.getElementById("info-panel");
if (infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden")) {
return infoPanel;
}
return null;
}
export class PresentationController {
constructor({
connector,
pathFactory = createConnectorPath,
requestAnimationFrame = nextAnimationFrame,
} = {}) {
this.connector = connector;
this.pathFactory = pathFactory;
this.requestAnimationFrame = requestAnimationFrame;
this.active = null;
this.timeoutId = null;
}
isActive(owner = null) {
if (!this.active) return false;
return owner ? this.active.request.owner === owner : true;
}
clearTimeout() {
if (!this.timeoutId) return;
window.clearTimeout(this.timeoutId);
this.timeoutId = null;
}
dismiss(reason = "dismiss") {
if (!this.active) return false;
const active = this.active;
this.active = null;
this.clearTimeout();
active.request.connector?.instance?.hide?.();
this.connector?.hide?.();
active.request.card?.hide?.();
active.request.onDismiss?.(reason);
return true;
}
scheduleLifetime(request) {
this.clearTimeout();
if (request.lifetime?.mode !== "timeout") return;
const timeoutMs = Number(request.lifetime.timeoutMs);
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return;
this.timeoutId = window.setTimeout(() => {
this.dismiss("timeout");
}, timeoutMs);
}
getConnectorInstance(request) {
return request.connector?.instance || this.connector || null;
}
getConnectorPath(request) {
const connectorRequest = request.connector;
if (!connectorRequest?.enabled) return null;
const source = connectorRequest.sourceProvider?.();
if (!source) return null;
const target = connectorRequest.targetProvider?.() || getDefaultCardTarget();
if (!target) return null;
const sourceIsRect =
Number.isFinite(source.x) &&
Number.isFinite(source.y) &&
Number.isFinite(source.width) &&
Number.isFinite(source.height);
const sourceAnchor = sourceIsRect
? { x: source.x + source.width * 0.5, y: source.y + source.height * 0.5 }
: source;
const options = {
...(connectorRequest.options || {}),
...(sourceIsRect && !connectorRequest.options?.sourceRect ? { sourceRect: source } : {}),
};
return this.pathFactory(sourceAnchor, target, options);
}
update({ animate = false } = {}) {
if (!this.active) return false;
const { request } = this.active;
const connector = this.getConnectorInstance(request);
if (!connector || request.connector?.enabled !== true) return false;
const path = this.getConnectorPath(request);
if (!path) {
connector.hide?.();
return false;
}
return connector.render(path, { animate }) === true;
}
async waitForConnector(request, context) {
const timeoutMs =
Number(request.connector?.readyTimeoutMs) || DEFAULT_CONNECTOR_READY_TIMEOUT_MS;
const startedAt = performance.now();
let ready = false;
while (isCurrentContext(context) && this.active?.request === request) {
ready = this.update({ animate: !ready });
if (ready) break;
if (performance.now() - startedAt >= timeoutMs) break;
await this.requestAnimationFrame();
}
return ready;
}
async present(request, { context } = {}) {
if (!request?.id || !request.card || !isCurrentContext(context)) return false;
this.dismiss("replace");
this.active = { id: request.id, request };
request.card.render?.({ reveal: false });
await this.requestAnimationFrame();
if (!isCurrentContext(context) || this.active?.request !== request) {
this.dismiss("interrupted");
return false;
}
const connectorReady =
request.connector?.enabled === true
? await this.waitForConnector(request, context)
: false;
if (!isCurrentContext(context) || this.active?.request !== request) {
this.dismiss("interrupted");
return false;
}
if (connectorReady) {
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;
}
}
request.card.render?.({ reveal: true });
await this.requestAnimationFrame();
if (!isCurrentContext(context) || this.active?.request !== request) {
this.dismiss("interrupted");
return false;
}
this.update({ animate: !connectorReady });
this.scheduleLifetime(request);
return true;
}
}

View File

@@ -0,0 +1,137 @@
import { describe, expect, test } from "bun:test";
import { PresentationController } from "./presentation-controller.js";
function installWindow() {
globalThis.HTMLElement = class HTMLElement {};
globalThis.document = {
getElementById: () => null,
};
globalThis.performance = {
now: () => Date.now(),
};
globalThis.window = {
requestAnimationFrame: (callback) => setTimeout(callback, 0),
setTimeout,
clearTimeout,
};
}
function wait(ms = 8) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function createController(options = {}) {
installWindow();
const calls = [];
const pathCalls = [];
const connector = {
render: () => {
calls.push("connector:render");
return options.connectorReady ?? true;
},
hide: () => calls.push("connector:hide"),
};
const controller = new PresentationController({
connector,
pathFactory: (source, target, pathOptions) => {
pathCalls.push({ source, target, options: pathOptions });
return { points: [{ x: 0, y: 0 }, { x: 10, y: 10 }], start: { x: 0, y: 0 }, end: { x: 10, y: 10 } };
},
});
return { calls, controller, pathCalls };
}
function createRequest(overrides = {}) {
return {
id: overrides.id || "presentation:one",
owner: overrides.owner || "motion",
card: {
render: ({ reveal }) => overrides.calls?.push(`card:${reveal ? "show" : "stage"}`),
hide: () => overrides.calls?.push("card:hide"),
},
connector: {
enabled: true,
sourceProvider: () => ({ x: 1, y: 2, width: 12, height: 12 }),
targetProvider: () => ({ x: 50, y: 60, width: 120, height: 80 }),
readyTimeoutMs: 1,
drawMs: 1,
},
lifetime: { mode: "persistent" },
onDismiss: (reason) => overrides.calls?.push(`dismiss:${reason}`),
...overrides,
};
}
describe("PresentationController", () => {
test("persistent presentation remains active without timeout", async () => {
const { calls, controller } = createController();
const request = createRequest({ calls });
const presented = await controller.present(request);
await wait();
expect(presented).toBe(true);
expect(controller.isActive("motion")).toBe(true);
expect(calls).toContain("card:stage");
expect(calls).toContain("card:show");
});
test("timeout lifetime dismisses the active presentation", async () => {
const { calls, controller } = createController();
const request = createRequest({
calls,
lifetime: { mode: "timeout", timeoutMs: 2 },
});
await controller.present(request);
await wait(12);
expect(controller.isActive()).toBe(false);
expect(calls).toContain("dismiss:timeout");
expect(calls).toContain("card:hide");
});
test("present replaces the previous presentation", async () => {
const { calls, controller } = createController();
await controller.present(createRequest({ calls, id: "one" }));
await controller.present(createRequest({ calls, id: "two" }));
expect(controller.isActive("motion")).toBe(true);
expect(calls).toContain("dismiss:replace");
});
test("dismiss clears card and connector", async () => {
const { calls, controller } = createController();
await controller.present(createRequest({ calls }));
const dismissed = controller.dismiss("owner_stop");
expect(dismissed).toBe(true);
expect(controller.isActive()).toBe(false);
expect(calls).toContain("connector:hide");
expect(calls).toContain("card:hide");
expect(calls).toContain("dismiss:owner_stop");
});
test("update refreshes connector anchors", async () => {
const { calls, controller } = createController();
await controller.present(createRequest({ calls }));
calls.length = 0;
const updated = controller.update();
expect(updated).toBe(true);
expect(calls).toEqual(["connector:render"]);
});
test("rect source anchors are passed as center point plus sourceRect", async () => {
const { controller, pathCalls } = createController();
await controller.present(createRequest());
expect(pathCalls[0].source).toEqual({ x: 7, y: 8 });
expect(pathCalls[0].options.sourceRect).toEqual({ x: 1, y: 2, width: 12, height: 12 });
});
});

View File

@@ -388,7 +388,7 @@ function syncSettingsToggle(visible) {
}
}
function setPanelVisible(visible) {
function setPanelVisible(visible, { persist = true } = {}) {
const { panel } = getElements();
if (!panel) return;
mediaPanel?.setVisible(visible);
@@ -396,12 +396,12 @@ function setPanelVisible(visible) {
updateToggleButton(visible);
syncSettingsToggle(visible);
window.dispatchEvent(new CustomEvent("earth:tv-visibility-change", {
detail: { visible },
detail: { visible, persist },
}));
}
export function setTVPanelVisible(visible) {
setPanelVisible(visible);
export function setTVPanelVisible(visible, options = {}) {
setPanelVisible(visible, options);
}
function clearReformState() {
@@ -582,6 +582,10 @@ export function openTVPanelTab(tab = "live") {
setActiveTab(tab);
}
export function setActiveTVTab(tab = "live") {
setActiveTab(tab);
}
export function isTVPanelVisible() {
return mediaPanel?.isVisible() ?? !getElements().panel?.classList.contains("hud-panel-hidden");
}

View File

@@ -484,7 +484,8 @@ body {
flex: 1 1 auto;
min-height: 0;
height: 100%;
overflow: auto;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 14px;
@@ -530,6 +531,10 @@ body {
}
.playground-chat__input-wrap {
box-sizing: border-box;
width: 100%;
max-width: 100%;
overflow: hidden;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.22);
background:
@@ -665,6 +670,9 @@ body {
}
.playground-message__bubble {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
width: 100%;
border-radius: 18px;
padding: 14px 16px;
@@ -839,10 +847,16 @@ body {
.playground-message__markdown {
font-size: 13px;
line-height: 1.7;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.playground-message__markdown .markdown-renderer {
color: inherit;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.playground-message__markdown .markdown-renderer > :last-child {
@@ -976,6 +990,11 @@ body {
gap: 8px;
}
.playground-preset-strip__actions--composer {
gap: 6px;
margin-bottom: 6px;
}
.playground-preset-strip--chat {
gap: 8px;
margin-bottom: 14px;
@@ -988,6 +1007,11 @@ body {
padding: 4px 12px;
}
.playground-preset-strip__actions--composer .ant-tag-checkable {
margin-bottom: 0;
padding: 3px 10px;
}
.playground-preset-strip__actions .ant-tag-checkable-checked {
background: linear-gradient(135deg, #0f766e, #14b8a6);
color: #f8fafc;

View File

@@ -73,9 +73,13 @@ export const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: 'Planet 使用手册', group: 'Manual', order: 2 },
en: { title: 'Planet Manual', group: 'Manual', order: 2 },
},
'faq.md': {
zh: { title: '常见问题', group: 'Manual', order: 3 },
en: { title: 'FAQ', group: 'Manual', order: 3 },
},
'location-pipeline-user.md': {
zh: { title: 'Earth 位置候选采集使用手册', group: 'Manual', order: 3 },
en: { title: 'Earth Location Candidate Collection User Guide', group: 'Manual', order: 3 },
zh: { title: 'Earth 位置候选采集使用手册', group: 'Manual', order: 4 },
en: { title: 'Earth Location Candidate Collection User Guide', group: 'Manual', order: 4 },
},
'earth-frontend-context.md': {
zh: { title: 'Earth 前端结构', group: 'Earth', order: 10 },

View File

@@ -244,6 +244,7 @@ function Playground() {
const pollTimerRef = useRef<number | null>(null)
const messagesContainerRef = useRef<HTMLDivElement | null>(null)
const messagesShellRef = useRef<HTMLDivElement | null>(null)
const composerRef = useRef<HTMLDivElement | null>(null)
const forceScrollToBottomRef = useRef(true)
const selectedPreset = useMemo(
@@ -256,6 +257,7 @@ function Playground() {
)
const streaming = Boolean(activeAssistantMessage)
const sendButtonTooltip = requestPending ? '发送中' : streaming ? '停止生成' : '发送消息'
const composerExpanded = composerFocused || !!inputValue
const applyThreadSnapshot = (thread: PlaygroundThreadRecord | null) => {
if (!thread) {
@@ -423,6 +425,7 @@ function Playground() {
}, [])
const handleApplyPreset = (preset: PlaygroundPreset) => {
setComposerFocused(true)
setSelectedPresetKey(preset.key)
setTitle(preset.values.title)
setObjective(preset.values.objective)
@@ -430,6 +433,15 @@ function Playground() {
setInputValue(preset.values.message)
}
const handleComposerBlur = () => {
window.setTimeout(() => {
const composer = composerRef.current
if (!composer || !composer.contains(document.activeElement)) {
setComposerFocused(false)
}
}, 0)
}
const handleStop = async () => {
if (!token || !activeAssistantMessage) {
return
@@ -864,9 +876,17 @@ function Playground() {
</div>
<div className="playground-chat__composer">
<div className={`playground-chat__input-wrap${composerFocused || !!inputValue ? ' playground-chat__input-wrap--expanded' : ''}`}>
{(composerFocused || !!inputValue) && (
<div className="playground-preset-strip__actions">
<div
ref={composerRef}
className={`playground-chat__input-wrap${composerExpanded ? ' playground-chat__input-wrap--expanded' : ''}`}
onFocusCapture={() => setComposerFocused(true)}
onBlurCapture={handleComposerBlur}
>
{composerExpanded && (
<div
className="playground-preset-strip__actions playground-preset-strip__actions--composer"
onMouseDown={(event) => event.preventDefault()}
>
{PLAYGROUND_PRESETS.map((preset) => (
<Tag.CheckableTag
key={preset.key}
@@ -882,11 +902,9 @@ function Playground() {
<Input.TextArea
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
autoSize={composerFocused || !!inputValue ? { minRows: 4, maxRows: 10 } : { minRows: 1, maxRows: 1 }}
autoSize={composerExpanded ? { minRows: 4, maxRows: 10 } : { minRows: 1, maxRows: 1 }}
placeholder="在这里输入本次分析请求..."
className="playground-chat__input"
onFocus={() => setComposerFocused(true)}
onBlur={() => setComposerFocused(false)}
onPressEnter={(event) => {
if (!event.shiftKey) {
event.preventDefault()
@@ -894,7 +912,7 @@ function Playground() {
}
}}
/>
{!(composerFocused || !!inputValue) && (
{!composerExpanded && (
<Tooltip title={sendButtonTooltip}>
<Button
type="text"
@@ -907,7 +925,7 @@ function Playground() {
</Tooltip>
)}
</div>
{(composerFocused || !!inputValue) && (
{composerExpanded && (
<div className="playground-chat__actions">
<div className="playground-chat__hints">
<Tag>{title}</Tag>

View File

@@ -7,6 +7,8 @@ import {
CheckCircleOutlined,
DeleteOutlined,
EditOutlined,
EyeInvisibleOutlined,
EyeOutlined,
PlayCircleOutlined,
PlusOutlined,
ReloadOutlined,
@@ -129,17 +131,31 @@ interface TVSettings {
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 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
@@ -330,6 +346,11 @@ function Settings() {
const [refreshingAiPreset, setRefreshingAiPreset] = useState(false)
const [savingTvSettings, setSavingTvSettings] = useState(false)
const [savingIntegrations, setSavingIntegrations] = useState(false)
const [aiProviderSaveFeedback, setAiProviderSaveFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
const [testingAiProviderConnection, setTestingAiProviderConnection] = useState(false)
const [revealedAiProviderSecrets, setRevealedAiProviderSecrets] = useState<Record<string, { api_key: string; service_token: string }>>({})
const [aiProviderApiKeyRevealed, setAiProviderApiKeyRevealed] = useState(false)
const [serviceTokenRevealed, setServiceTokenRevealed] = useState(false)
const [testingCredentialProvider, setTestingCredentialProvider] = useState<string | null>(null)
const [credentialGuide, setCredentialGuide] = useState<CredentialGuide | null>(null)
const [credentialGuideOpen, setCredentialGuideOpen] = useState(false)
@@ -348,6 +369,9 @@ function Settings() {
const [customSourceForm] = Form.useForm()
const [tvEditForm] = Form.useForm<TVStreamSource>()
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], integrationForm)
const selectedAiProviderSecret = selectedAiProvider
? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key
: integrations?.ai_provider.api_key
const customCollectors: CollectorSettings[] = useMemo(() => customSourceConfigs.map((config) => ({
id: -(config.config_id || 0),
name: config.name,
@@ -458,18 +482,21 @@ function Settings() {
integrationForm.setFieldsValue({
ai_provider: {
service_url: integrations.ai_provider.service_url,
service_token: '',
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: '',
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,
clear_service_token: false,
clear_api_key: false,
},
barentswatch: {
endpoint: integrations.barentswatch.endpoint,
@@ -607,8 +634,10 @@ function Settings() {
const saveIntegrations = async (values: any) => {
try {
setSavingIntegrations(true)
const aiProviderPayload = buildAiProviderDraftPayload(values)
const payload = {
...values,
ai_provider: aiProviderPayload,
barentswatch: {
...values.barentswatch,
client_secret:
@@ -619,15 +648,149 @@ function Settings() {
}
const response = await axios.put('/api/v1/settings/integrations', payload)
setIntegrations(response.data.integrations)
setAiProviderSaveFeedback({ type: 'success', message: 'AI 配置已保存为全局默认配置' })
message.success('外部集成配置已保存')
await fetchSettings()
} catch {
setAiProviderSaveFeedback({ type: 'error', message: 'AI 配置保存失败' })
message.error('外部集成配置保存失败')
} finally {
setSavingIntegrations(false)
}
}
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 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('*')
return {
...values.ai_provider,
default_provider: selectedProvider,
api_key: apiKeyUnchanged ? '' : nextApiKey,
service_token: serviceTokenUnchanged ? '' : nextServiceToken,
}
}
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 handleAiProviderApiKeyVisibleChange = async (visible: boolean) => {
const provider = String(integrationForm.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) {
integrationForm.setFieldValue(['ai_provider', 'api_key'], secrets.api_key)
}
setAiProviderApiKeyRevealed(true)
} catch {
message.error('读取 LLM API Key 失败')
}
return
}
const currentValue = String(integrationForm.getFieldValue(['ai_provider', 'api_key']) || '')
const revealedValue = revealedAiProviderSecrets[provider]?.api_key
if (revealedValue && currentValue === revealedValue) {
integrationForm.setFieldValue(['ai_provider', 'api_key'], providerSecret?.preview || '')
}
setAiProviderApiKeyRevealed(false)
}
const handleServiceTokenVisibleChange = async (visible: boolean) => {
const provider = String(integrationForm.getFieldValue(['ai_provider', 'provider']) || integrations?.ai_provider.provider || 'minimax')
if (visible) {
try {
const secrets = await revealAiProviderSecrets(provider)
if (secrets.service_token) {
integrationForm.setFieldValue(['ai_provider', 'service_token'], secrets.service_token)
}
setServiceTokenRevealed(true)
} catch {
message.error('读取代理 Token 失败')
}
return
}
const currentValue = String(integrationForm.getFieldValue(['ai_provider', 'service_token']) || '')
const revealedValue = revealedAiProviderSecrets[provider]?.service_token
if (revealedValue && currentValue === revealedValue) {
integrationForm.setFieldValue(['ai_provider', 'service_token'], integrations?.ai_provider.service_token.preview || '')
}
setServiceTokenRevealed(false)
}
const testAiProviderConnection = async () => {
try {
setTestingAiProviderConnection(true)
const values = integrationForm.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)
integrationForm.setFieldsValue({
ai_provider: {
service_url: response.data.integrations.ai_provider.service_url,
service_token: response.data.integrations.ai_provider.service_token.configured
? response.data.integrations.ai_provider.service_token.preview
: '',
default_provider: response.data.integrations.ai_provider.default_provider || response.data.integrations.ai_provider.provider,
provider: response.data.integrations.ai_provider.provider,
provider_api: response.data.integrations.ai_provider.provider_api,
base_url: response.data.integrations.ai_provider.base_url,
model: response.data.integrations.ai_provider.model,
api_key: response.data.integrations.ai_provider.api_key.configured
? response.data.integrations.ai_provider.api_key.preview
: '',
max_tokens: response.data.integrations.ai_provider.max_tokens,
anthropic_version: response.data.integrations.ai_provider.anthropic_version,
timeout_seconds: response.data.integrations.ai_provider.timeout_seconds,
retry_attempts: response.data.integrations.ai_provider.retry_attempts,
},
})
}
setAiProviderSaveFeedback({ type: 'success', message: response.data.message || 'AI Provider 连接成功,已保存为全局默认配置' })
message.success(response.data.message || 'AI Provider 连接成功')
} else {
setAiProviderSaveFeedback({ 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 连接失败'
setAiProviderSaveFeedback({ type: 'error', message: errorMessage })
message.error(errorMessage)
} finally {
setTestingAiProviderConnection(false)
}
}
const saveSelectedCollectorSettings = async () => {
if (!selectedCollector || !selectedCollectorConfig) return
try {
@@ -1064,21 +1227,31 @@ function Settings() {
}
}
const applyAiProviderPreset = (preset: AIProviderPreset) => {
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 : ''
integrationForm.setFieldsValue({
ai_provider: {
provider: preset.provider,
provider_api: preset.provider_api,
base_url: preset.base_url,
model: preset.model,
max_tokens: preset.provider_api === 'anthropic-messages'
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: '2023-06-01',
: DEFAULT_PROVIDER_MAX_TOKENS),
anthropic_version: savedProvider?.anthropic_version || '2023-06-01',
},
})
}
const applyAiProviderPreset = (preset: AIProviderPreset) => {
applyAiProviderSelection(preset.provider, preset)
}
const refreshSelectedAiProviderPreset = async () => {
const provider = integrationForm.getFieldValue(['ai_provider', 'provider'])
if (!provider) return
@@ -1624,7 +1797,12 @@ function Settings() {
forceRender: true,
children: (
<SettingsPanel loading={loading}>
<Form form={integrationForm} layout="vertical" onFinish={saveIntegrations}>
<Form
form={integrationForm}
layout="vertical"
onFinish={saveIntegrations}
onValuesChange={() => setAiProviderSaveFeedback(null)}
>
<Card size="small" title={<Space><ApiOutlined />LLM Provider</Space>}>
<Form.Item name={['ai_provider', 'provider']} label="Provider">
<Select
@@ -1635,8 +1813,7 @@ function Settings() {
label: `${preset.label} · ${preset.provider_api}`,
}))}
onChange={(value) => {
const preset = aiProviderPresets.find((item) => item.provider === value)
if (preset) applyAiProviderPreset(preset)
applyAiProviderSelection(value)
}}
/>
</Form.Item>
@@ -1646,13 +1823,22 @@ function Settings() {
<Input placeholder="https://api.example.com/v1" />
</Form.Item>
<Form.Item label=" ">
<Button
icon={<SyncOutlined />}
loading={refreshingAiPreset}
onClick={refreshSelectedAiProviderPreset}
>
刷新
</Button>
<Space>
<Button
icon={<SyncOutlined />}
loading={refreshingAiPreset}
onClick={refreshSelectedAiProviderPreset}
>
刷新
</Button>
<Button
icon={<PlayCircleOutlined />}
loading={testingAiProviderConnection}
onClick={() => { void testAiProviderConnection() }}
>
测试连接
</Button>
</Space>
</Form.Item>
</div>
@@ -1678,21 +1864,28 @@ function Settings() {
<Form.Item label="LLM API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={integrations?.ai_provider.api_key.configured ? 'green' : 'default'}>
{integrations?.ai_provider.api_key.configured
? `已配置 ${integrations.ai_provider.api_key.preview}`
: '未配置'}
<Tag color={selectedAiProviderSecret?.configured ? 'green' : 'default'}>
{selectedAiProviderSecret?.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary">留空表示保留现有 key。</Text>
<Text type="secondary">保存时只会更新当前 Provider 的 key。</Text>
</Space>
<Form.Item name={['ai_provider', 'api_key']} noStyle>
<Input.Password autoComplete="new-password" placeholder="输入新的 LLM API key" />
<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>
<Form.Item name={['ai_provider', 'clear_api_key']} valuePropName="checked">
<Checkbox>清除当前 LLM API key</Checkbox>
</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%' }} />
@@ -1715,20 +1908,27 @@ function Settings() {
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={integrations?.ai_provider.service_token.configured ? 'green' : 'default'}>
{integrations?.ai_provider.service_token.configured
? `已配置 ${integrations.ai_provider.service_token.preview}`
: '未配置'}
{integrations?.ai_provider.service_token.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary">通常不需要改;用于 backend 调本地 aiprovider。</Text>
</Space>
<Form.Item name={['ai_provider', 'service_token']} noStyle>
<Input.Password autoComplete="new-password" placeholder="输入新的代理 token" />
<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>
<Form.Item name={['ai_provider', 'clear_service_token']} valuePropName="checked">
<Checkbox>清除当前代理 token</Checkbox>
</Form.Item>
</Card>
</Card>
@@ -1740,6 +1940,14 @@ function Settings() {
>
保存 AI 配置
</Button>
{aiProviderSaveFeedback ? (
<Alert
showIcon
type={aiProviderSaveFeedback.type}
message={aiProviderSaveFeedback.message}
style={{ marginTop: 12 }}
/>
) : null}
</Form>
</SettingsPanel>
),

11
motion_agent/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
"""Local motion capture gesture agent for Planet Earth displays."""
from .config import MotionAgentConfig
from .events import GestureEvent, HeartbeatEvent, StatusEvent
__all__ = [
"GestureEvent",
"HeartbeatEvent",
"MotionAgentConfig",
"StatusEvent",
]

5
motion_agent/__main__.py Normal file
View File

@@ -0,0 +1,5 @@
from .cli import main
if __name__ == "__main__":
main()

147
motion_agent/cameras.py Normal file
View File

@@ -0,0 +1,147 @@
"""Camera input adapters for the local motion capture agent."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol
class MotionAgentCameraError(RuntimeError):
"""Raised when a configured camera source cannot be opened."""
class MotionAgentDependencyError(RuntimeError):
"""Raised when an optional runtime dependency is missing."""
class CameraInput(Protocol):
camera_id: str
def open(self) -> None:
"""Open the camera source."""
def read(self) -> Any:
"""Return the next frame or raise a camera error."""
def close(self) -> None:
"""Release the camera source."""
@dataclass(frozen=True)
class UsbCameraSpec:
index: int
width: int = 1280
height: int = 720
fps: int = 30
@dataclass(frozen=True)
class UrlCameraSpec:
url: str
camera_id: str | None = None
class UsbCameraInput:
"""OpenCV-backed USB camera input.
OpenCV is intentionally imported lazily so tests and protocol-only runs do
not require native camera dependencies.
"""
def __init__(self, spec: UsbCameraSpec):
self.spec = spec
self.camera_id = f"usb:{spec.index}"
self._capture: Any = None
def open(self) -> None:
device_path = Path(f"/dev/video{self.spec.index}")
if Path("/dev").exists() and not device_path.exists():
raise MotionAgentCameraError(
f"USB camera index {self.spec.index} was not found at {device_path}. "
"Check camera passthrough or set MOTION_AGENT_CAMERA_INDEXES to the available indexes."
)
try:
import cv2 # type: ignore
except ImportError as exc:
raise MotionAgentDependencyError(
"OpenCV is required for camera capture. Add opencv-python with uv "
"or start the agent with --dry-run for protocol testing."
) from exc
capture = cv2.VideoCapture(self.spec.index)
if not capture or not capture.isOpened():
raise MotionAgentCameraError(f"Unable to open USB camera index {self.spec.index}.")
capture.set(cv2.CAP_PROP_FRAME_WIDTH, self.spec.width)
capture.set(cv2.CAP_PROP_FRAME_HEIGHT, self.spec.height)
capture.set(cv2.CAP_PROP_FPS, self.spec.fps)
self._capture = capture
def read(self) -> Any:
if self._capture is None:
raise MotionAgentCameraError(f"Camera {self.camera_id} is not open.")
ok, frame = self._capture.read()
if not ok:
raise MotionAgentCameraError(f"Camera {self.camera_id} did not return a frame.")
return frame
def close(self) -> None:
if self._capture is not None:
self._capture.release()
self._capture = None
class UrlCameraInput:
"""OpenCV-backed URL camera input for RTSP/HTTP/Web camera bridges."""
def __init__(self, spec: UrlCameraSpec):
self.spec = spec
self.camera_id = spec.camera_id or f"url:{spec.url}"
self._capture: Any = None
def open(self) -> None:
try:
import cv2 # type: ignore
except ImportError as exc:
raise MotionAgentDependencyError(
"OpenCV is required for URL camera capture. Add opencv-python with uv "
"or start the agent with --dry-run for protocol testing."
) from exc
capture = cv2.VideoCapture(self.spec.url)
if not capture or not capture.isOpened():
raise MotionAgentCameraError(
f"Unable to open camera URL {self.spec.url}. Check the stream URL, "
"firewall, and LAN reachability."
)
self._capture = capture
def read(self) -> Any:
if self._capture is None:
raise MotionAgentCameraError(f"Camera {self.camera_id} is not open.")
ok, frame = self._capture.read()
if not ok:
raise MotionAgentCameraError(f"Camera {self.camera_id} did not return a frame.")
return frame
def close(self) -> None:
if self._capture is not None:
self._capture.release()
self._capture = None
class NullCameraInput:
"""No-op camera source used by dry-run and tests."""
camera_id = "dry-run:null-camera"
def open(self) -> None:
return None
def read(self) -> None:
return None
def close(self) -> None:
return None

65
motion_agent/cli.py Normal file
View File

@@ -0,0 +1,65 @@
"""Command line entrypoint for the local motion capture agent."""
from __future__ import annotations
import argparse
import asyncio
import sys
from dataclasses import replace
from .cameras import MotionAgentCameraError, MotionAgentDependencyError
from .config import MotionAgentConfig
from .server import MotionAgentServer
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run the Planet local motion capture agent.")
parser.add_argument("--host", default=None, help="WebSocket bind host.")
parser.add_argument("--port", type=int, default=None, help="WebSocket bind port.")
parser.add_argument("--camera-indexes", default=None, help="Comma-separated USB camera indexes.")
parser.add_argument("--camera-urls", default=None, help="Comma-separated RTSP/HTTP camera URLs.")
parser.add_argument("--mode", choices=["auto", "single", "dual"], default=None)
parser.add_argument("--dry-run", action="store_true", help="Start without camera/CV dependencies.")
return parser
def _parse_indexes(raw: str | None, fallback: tuple[int, ...]) -> tuple[int, ...]:
if raw is None:
return fallback
values = [item.strip() for item in raw.split(",") if item.strip()]
return tuple(int(item) for item in values) or fallback
def _parse_urls(raw: str | None, fallback: tuple[str, ...]) -> tuple[str, ...]:
if raw is None:
return fallback
return tuple(item.strip() for item in raw.split(",") if item.strip())
def config_from_args(args: argparse.Namespace) -> MotionAgentConfig:
config = MotionAgentConfig.from_env()
return replace(
config,
host=args.host or config.host,
port=args.port or config.port,
camera_indexes=_parse_indexes(args.camera_indexes, config.camera_indexes),
camera_urls=_parse_urls(args.camera_urls, config.camera_urls),
mode=args.mode or config.mode,
dry_run=args.dry_run or config.dry_run,
)
async def async_main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
config = config_from_args(args)
try:
server = MotionAgentServer(config)
await server.run()
except (MotionAgentCameraError, MotionAgentDependencyError, RuntimeError) as exc:
print(f"Motion agent failed: {exc}", file=sys.stderr)
return 2
return 0
def main(argv: list[str] | None = None) -> None:
raise SystemExit(asyncio.run(async_main(argv)))

65
motion_agent/config.py Normal file
View File

@@ -0,0 +1,65 @@
"""Configuration for the local motion capture agent."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
def _parse_camera_indexes(raw: str | None) -> tuple[int, ...]:
if not raw:
return (0,)
indexes: list[int] = []
for item in raw.split(","):
value = item.strip()
if not value:
continue
indexes.append(int(value))
return tuple(indexes or [0])
def _parse_camera_urls(raw: str | None) -> tuple[str, ...]:
if not raw:
return ()
return tuple(item.strip() for item in raw.split(",") if item.strip())
@dataclass(frozen=True)
class MotionAgentConfig:
host: str = "127.0.0.1"
port: int = 8765
path: str = "/ws/gestures"
camera_indexes: tuple[int, ...] = field(default_factory=lambda: (0,))
camera_urls: tuple[str, ...] = field(default_factory=tuple)
camera_width: int = 1280
camera_height: int = 720
camera_fps: int = 30
mode: str = "auto"
confidence_threshold: float = 0.72
cooldown_ms: int = 450
heartbeat_interval_ms: int = 1000
max_event_hz: int = 20
dry_run: bool = False
@property
def websocket_url(self) -> str:
return f"ws://{self.host}:{self.port}{self.path}"
@classmethod
def from_env(cls) -> "MotionAgentConfig":
return cls(
host=os.getenv("MOTION_AGENT_HOST", "127.0.0.1"),
port=int(os.getenv("MOTION_AGENT_PORT", "8765")),
path=os.getenv("MOTION_AGENT_PATH", "/ws/gestures"),
camera_indexes=_parse_camera_indexes(os.getenv("MOTION_AGENT_CAMERA_INDEXES")),
camera_urls=_parse_camera_urls(os.getenv("MOTION_AGENT_CAMERA_URLS")),
camera_width=int(os.getenv("MOTION_AGENT_CAMERA_WIDTH", "1280")),
camera_height=int(os.getenv("MOTION_AGENT_CAMERA_HEIGHT", "720")),
camera_fps=int(os.getenv("MOTION_AGENT_CAMERA_FPS", "30")),
mode=os.getenv("MOTION_AGENT_MODE", "auto"),
confidence_threshold=float(os.getenv("MOTION_AGENT_CONFIDENCE_THRESHOLD", "0.72")),
cooldown_ms=int(os.getenv("MOTION_AGENT_COOLDOWN_MS", "450")),
heartbeat_interval_ms=int(os.getenv("MOTION_AGENT_HEARTBEAT_MS", "1000")),
max_event_hz=int(os.getenv("MOTION_AGENT_MAX_EVENT_HZ", "20")),
dry_run=os.getenv("MOTION_AGENT_DRY_RUN", "").lower() in {"1", "true", "yes"},
)

96
motion_agent/events.py Normal file
View File

@@ -0,0 +1,96 @@
"""Stable event models emitted by the local motion capture agent."""
from __future__ import annotations
import json
import time
from dataclasses import asdict, dataclass, field
from typing import Any, Literal
GestureName = Literal["rotate_left", "rotate_right", "zoom_in", "zoom_out", "confirm"]
GesturePhase = Literal["start", "active", "end", "discrete"]
def now_ms() -> int:
return int(time.time() * 1000)
@dataclass(frozen=True)
class GestureEvent:
gesture: GestureName
phase: GesturePhase = "discrete"
confidence: float = 1.0
intensity: float = 1.0
timestamp_ms: int = field(default_factory=now_ms)
seq: int = 0
source: str = "motion-agent"
mode: str = "single"
payload: dict[str, Any] = field(default_factory=dict)
type: Literal["gesture"] = "gesture"
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def to_json(self) -> str:
return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))
@dataclass(frozen=True)
class StatusEvent:
connected: bool
camera_count: int
active_camera_ids: tuple[str, ...] = ()
mode: str = "single"
recognizer: str = "mediapipe-opencv"
fps: float = 0.0
last_gesture: str | None = None
error: str | None = None
timestamp_ms: int = field(default_factory=now_ms)
source: str = "motion-agent"
type: Literal["status"] = "status"
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def to_json(self) -> str:
return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))
@dataclass(frozen=True)
class HeartbeatEvent:
timestamp_ms: int = field(default_factory=now_ms)
source: str = "motion-agent"
type: Literal["heartbeat"] = "heartbeat"
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def to_json(self) -> str:
return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))
@dataclass(frozen=True)
class SkeletonJoint:
id: str
x: float
y: float
confidence: float = 1.0
@dataclass(frozen=True)
class SkeletonEvent:
joints: list[SkeletonJoint]
bones: list[tuple[str, str]]
matched_gesture: GestureName | None = None
confidence: float = 0.0
camera_id: str = "unknown"
timestamp_ms: int = field(default_factory=now_ms)
source: str = "motion-agent"
mode: str = "single"
type: Literal["skeleton"] = "skeleton"
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def to_json(self) -> str:
return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"))

118
motion_agent/recognizer.py Normal file
View File

@@ -0,0 +1,118 @@
"""Gesture recognizer interfaces.
The production recognizer is intentionally thin in this skeleton. It validates
that optional CV dependencies are available and keeps the protocol independent
from any specific model implementation.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Protocol
from .cameras import MotionAgentDependencyError
from .events import GestureName, SkeletonEvent, SkeletonJoint, now_ms
@dataclass(frozen=True)
class GestureObservation:
gesture: GestureName
confidence: float
intensity: float = 1.0
timestamp_ms: int | None = None
class GestureRecognizer(Protocol):
name: str
def recognize(self, frame: Any) -> GestureObservation | None:
"""Return a gesture observation for the current frame."""
def debug_skeleton(
self,
frame: Any,
*,
camera_id: str,
mode: str,
matched_gesture: GestureName | None = None,
confidence: float = 0.0,
) -> SkeletonEvent | None:
"""Return normalized skeleton debug data when available."""
class MediaPipeGestureRecognizer:
name = "mediapipe-opencv"
def __init__(self) -> None:
try:
import cv2 # noqa: F401
import mediapipe # noqa: F401
except ImportError as exc:
raise MotionAgentDependencyError(
"MediaPipe and OpenCV are required for live gesture recognition. "
"Add mediapipe and opencv-python with uv, or use --dry-run for protocol testing."
) from exc
def recognize(self, frame: Any) -> GestureObservation | None:
_ = frame
return None
def debug_skeleton(
self,
frame: Any,
*,
camera_id: str,
mode: str,
matched_gesture: GestureName | None = None,
confidence: float = 0.0,
) -> SkeletonEvent | None:
_ = frame, camera_id, mode, matched_gesture, confidence
return None
class NullGestureRecognizer:
name = "dry-run"
def recognize(self, frame: Any) -> GestureObservation | None:
_ = frame
return None
def debug_skeleton(
self,
frame: Any,
*,
camera_id: str,
mode: str,
matched_gesture: GestureName | None = None,
confidence: float = 0.0,
) -> SkeletonEvent | None:
_ = frame
now = now_ms()
sway = ((now // 250) % 6 - 2.5) * 0.015
joints = [
SkeletonJoint("head", 0.5, 0.18, 1.0),
SkeletonJoint("neck", 0.5, 0.3, 1.0),
SkeletonJoint("left_shoulder", 0.38, 0.34, 1.0),
SkeletonJoint("right_shoulder", 0.62, 0.34, 1.0),
SkeletonJoint("left_elbow", 0.31 + sway, 0.48, 0.95),
SkeletonJoint("right_elbow", 0.69 - sway, 0.48, 0.95),
SkeletonJoint("left_wrist", 0.24 + sway, 0.62, 0.9),
SkeletonJoint("right_wrist", 0.76 - sway, 0.62, 0.9),
]
bones = [
("head", "neck"),
("neck", "left_shoulder"),
("neck", "right_shoulder"),
("left_shoulder", "left_elbow"),
("left_elbow", "left_wrist"),
("right_shoulder", "right_elbow"),
("right_elbow", "right_wrist"),
]
return SkeletonEvent(
joints=joints,
bones=bones,
matched_gesture=matched_gesture,
confidence=confidence,
camera_id=camera_id,
mode=mode,
)

192
motion_agent/server.py Normal file
View File

@@ -0,0 +1,192 @@
"""Async WebSocket server for local gesture events."""
from __future__ import annotations
import asyncio
from collections.abc import Iterable
from contextlib import suppress
from typing import Any
from .cameras import (
MotionAgentCameraError,
NullCameraInput,
UrlCameraInput,
UrlCameraSpec,
UsbCameraInput,
UsbCameraSpec,
)
from .config import MotionAgentConfig
from .events import GestureEvent, HeartbeatEvent, SkeletonEvent, StatusEvent
from .recognizer import GestureRecognizer, MediaPipeGestureRecognizer, NullGestureRecognizer
from .state import GestureStateMachine
class MotionAgentServer:
def __init__(
self,
config: MotionAgentConfig,
cameras: Iterable[Any] | None = None,
recognizer: GestureRecognizer | None = None,
) -> None:
self.config = config
self.cameras = list(cameras) if cameras is not None else self._build_cameras(config)
self.recognizer = recognizer if recognizer is not None else self._build_recognizer(config)
self.state = GestureStateMachine(
confidence_threshold=config.confidence_threshold,
cooldown_ms=config.cooldown_ms,
mode=self._resolve_mode(),
)
self.clients: set[Any] = set()
self.last_gesture: str | None = None
self.last_error: str | None = None
self._stop = asyncio.Event()
def _build_cameras(self, config: MotionAgentConfig) -> list[Any]:
if config.dry_run:
return [NullCameraInput()]
if config.camera_urls:
return [
UrlCameraInput(UrlCameraSpec(url=url, camera_id=f"url:{index}"))
for index, url in enumerate(config.camera_urls)
]
specs = [
UsbCameraSpec(
index=index,
width=config.camera_width,
height=config.camera_height,
fps=config.camera_fps,
)
for index in config.camera_indexes
]
return [UsbCameraInput(spec) for spec in specs]
def _build_recognizer(self, config: MotionAgentConfig) -> GestureRecognizer:
if config.dry_run:
return NullGestureRecognizer()
return MediaPipeGestureRecognizer()
def _resolve_mode(self) -> str:
if self.config.mode in {"single", "dual", "auto"}:
if self.config.mode != "auto":
return self.config.mode
return "dual" if len(self.cameras) >= 2 else "single"
def open_cameras(self) -> None:
opened = []
try:
for camera in self.cameras:
camera.open()
opened.append(camera)
except Exception:
for camera in opened:
with suppress(Exception):
camera.close()
raise
def close_cameras(self) -> None:
for camera in self.cameras:
with suppress(Exception):
camera.close()
def status_event(self, connected: bool = True) -> StatusEvent:
return StatusEvent(
connected=connected,
camera_count=len(self.cameras),
active_camera_ids=tuple(camera.camera_id for camera in self.cameras),
mode=self.state.mode,
recognizer=self.recognizer.name,
last_gesture=self.last_gesture,
error=self.last_error,
)
async def broadcast(
self,
event: GestureEvent | HeartbeatEvent | SkeletonEvent | StatusEvent,
) -> None:
if not self.clients:
return
payload = event.to_json()
stale = []
for websocket in self.clients:
try:
await websocket.send(payload)
except Exception:
stale.append(websocket)
for websocket in stale:
self.clients.discard(websocket)
async def handler(self, websocket: Any, path: str | None = None) -> None:
if path is not None and path != self.config.path:
await websocket.close(code=1008, reason="Unsupported motion agent path")
return
self.clients.add(websocket)
await websocket.send(self.status_event().to_json())
try:
async for _message in websocket:
await websocket.send(self.status_event().to_json())
finally:
self.clients.discard(websocket)
async def heartbeat_loop(self) -> None:
interval = max(0.1, self.config.heartbeat_interval_ms / 1000)
while not self._stop.is_set():
await self.broadcast(HeartbeatEvent())
await asyncio.sleep(interval)
async def recognition_loop(self) -> None:
min_interval = 1 / max(1, self.config.max_event_hz)
while not self._stop.is_set():
try:
for camera in self.cameras:
frame = camera.read()
observation = self.recognizer.recognize(frame)
matched_gesture = None
matched_confidence = 0.0
if observation is None:
event = None
else:
event = self.state.accept(observation)
if event is not None:
self.last_gesture = event.gesture
matched_gesture = event.gesture
matched_confidence = event.confidence
await self.broadcast(event)
skeleton = self.recognizer.debug_skeleton(
frame,
camera_id=camera.camera_id,
mode=self.state.mode,
matched_gesture=matched_gesture,
confidence=matched_confidence,
)
if skeleton is not None:
await self.broadcast(skeleton)
except MotionAgentCameraError as exc:
self.last_error = str(exc)
await self.broadcast(self.status_event(connected=True))
await asyncio.sleep(1)
await asyncio.sleep(min_interval)
async def run(self) -> None:
try:
import websockets
except ImportError as exc:
raise RuntimeError("websockets is required to run the motion agent server.") from exc
self.open_cameras()
async with websockets.serve(self.handler, self.config.host, self.config.port):
print(f"Motion agent listening on {self.config.websocket_url}", flush=True)
heartbeat_task = asyncio.create_task(self.heartbeat_loop())
recognition_task = asyncio.create_task(self.recognition_loop())
try:
await self._stop.wait()
finally:
heartbeat_task.cancel()
recognition_task.cancel()
with suppress(asyncio.CancelledError):
await heartbeat_task
with suppress(asyncio.CancelledError):
await recognition_task
self.close_cameras()
def stop(self) -> None:
self._stop.set()

44
motion_agent/state.py Normal file
View File

@@ -0,0 +1,44 @@
"""Gesture event state machine."""
from __future__ import annotations
from dataclasses import dataclass
from .events import GestureEvent
from .recognizer import GestureObservation
@dataclass
class GestureStateMachine:
confidence_threshold: float = 0.72
cooldown_ms: int = 450
mode: str = "single"
def __post_init__(self) -> None:
self._last_emit_by_gesture: dict[str, int] = {}
self._seq = 0
def accept(self, observation: GestureObservation) -> GestureEvent | None:
if observation.confidence < self.confidence_threshold:
return None
timestamp_ms = observation.timestamp_ms
if timestamp_ms is None:
from .events import now_ms
timestamp_ms = now_ms()
last_emit_at = self._last_emit_by_gesture.get(observation.gesture)
if last_emit_at is not None and timestamp_ms - last_emit_at < self.cooldown_ms:
return None
self._seq += 1
self._last_emit_by_gesture[observation.gesture] = timestamp_ms
return GestureEvent(
gesture=observation.gesture,
confidence=round(max(0.0, min(1.0, observation.confidence)), 4),
intensity=round(max(0.0, min(1.0, observation.intensity)), 4),
timestamp_ms=timestamp_ms,
seq=self._seq,
mode=self.mode,
)

450
planet.sh
View File

@@ -104,10 +104,13 @@ PORT_PRESTART_RETRY_INTERVAL="${PORT_PRESTART_RETRY_INTERVAL:-2}"
DEFAULT_BACKEND_PORT="${DEFAULT_BACKEND_PORT:-8000}"
DEFAULT_FRONTEND_PORT="${DEFAULT_FRONTEND_PORT:-3000}"
DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}"
DEFAULT_MOTION_AGENT_PORT="${DEFAULT_MOTION_AGENT_PORT:-8765}"
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
FRONTEND_PID_FILE="/tmp/planet_frontend.pid"
FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js"
MOTION_AGENT_PID_FILE="/tmp/planet_motion_agent.pid"
MOTION_AGENT_LOG_FILE="/tmp/planet_motion_agent.log"
AI_PROVIDER_BUILD_STAMP_FILE="$HOME/.cache/planet/aiprovider_build.sha256"
AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log"
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
@@ -533,6 +536,22 @@ log_lan_access_notes() {
fi
}
log_motion_agent_access_notes() {
local motion_agent_port="$1"
local lan_enabled="${2:-0}"
local recommended_lan_ip=""
log_note "Motion Agent 本机: ws://127.0.0.1:${motion_agent_port}/ws/gestures"
if [ "$lan_enabled" -eq 1 ]; then
if recommended_lan_ip="$(get_recommended_lan_ipv4)"; then
log_note "Motion Agent 局域网: ws://${recommended_lan_ip}:${motion_agent_port}/ws/gestures"
log_note "Earth 局域网页面可追加: ?motion=1&motionAgent=ws://${recommended_lan_ip}:${motion_agent_port}/ws/gestures"
else
log_note "Motion Agent 已对局域网开放,请使用本机局域网 IP 访问 :${motion_agent_port}/ws/gestures"
fi
fi
}
print_splash() {
clear_wait_spinner
printf "%b" "$CYAN"
@@ -882,6 +901,68 @@ ensure_uv_backend_deps() {
fi
}
motion_agent_live_deps_ready() {
"$SCRIPT_DIR/.venv/bin/python" - <<'PY' >/dev/null 2>&1
import cv2 # noqa: F401
import mediapipe # noqa: F401
PY
}
detect_motion_agent_camera_indexes() {
local devices=""
local device=""
local index=""
if command -v v4l2-ctl >/dev/null 2>&1; then
devices="$(v4l2-ctl --list-devices 2>/dev/null | sed -nE 's/^[[:space:]]*\\/dev\\/video([0-9]+).*/\\1/p' || true)"
fi
if [ -z "$devices" ]; then
for device in /dev/video*(N); do
[ -e "$device" ] || continue
index="${device#/dev/video}"
[[ "$index" =~ ^[0-9]+$ ]] || continue
printf "%s\n" "$index"
done | sort -n | awk '!seen[$0]++' | head -n 2 | paste -sd, -
return 0
fi
printf "%s\n" "$devices" | sort -n | awk '!seen[$0]++' | head -n 2 | paste -sd, -
}
is_wsl_environment() {
grep -qiE "(microsoft|wsl)" /proc/version 2>/dev/null
}
ensure_motion_agent_live_deps() {
local auto_install="${PLANET_MOTION_AGENT_AUTO_INSTALL:-1}"
motion_agent_live_deps_ready && return 0
case "$auto_install" in
0|false|no|off)
log_error "Motion Agent live 模式缺少 mediapipe/opencv-python,且 PLANET_MOTION_AGENT_AUTO_INSTALL=0"
exit 1
;;
esac
set_wait_detail "Motion Agent 缺少 CV 依赖,准备用 uv 自动安装"
if ! run_with_retry \
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
"Motion Agent CV 依赖安装失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次" \
"uv add mediapipe opencv-python" \
uv add mediapipe opencv-python; then
exit 1
fi
if ! motion_agent_live_deps_ready; then
log_error "Motion Agent CV 依赖安装后仍无法导入 mediapipe/opencv-python"
log_note "可先用 --motion-agent-dry-run 验证 Web 端连接;真实摄像头识别需要当前 Python 版本可用的 mediapipe/opencv-python。"
exit 1
fi
}
# Frontend runtime resolution
resolve_frontend_runtime() {
local shell_bin
@@ -1156,6 +1237,7 @@ fail_unreleased_port() {
if [ -z "$(collect_port_pids "$port" || true)" ]; then
log_error "端口 ${port} 当前环境内未发现占用进程,但端口仍不可用,请检查宿主机或外部环境占用"
print_port_listener_details "$port"
log_note "如果显示 Windows listener(如 svchost.exe / iphlpsvc),请优先用管理员 PowerShell 检查 portproxy:netsh interface portproxy show all;删除冲突转发或改用其他端口。"
else
log_error "端口 ${port} 清理失败,请检查占用进程"
if command -v lsof >/dev/null 2>&1; then
@@ -1164,6 +1246,7 @@ fail_unreleased_port() {
ss -ltnp "( sport = :${port} )" 2>/dev/null || true
fi
print_windows_port_listener_details "$port" || true
log_note "请手动停止占用进程,或改用其他端口。"
fi
exit 1
@@ -1198,6 +1281,101 @@ retry_wait_for_port_release() {
return 1
}
force_cleanup_external_port_listener() {
local port="$1"
local service_name="$2"
local output=""
local saw_windows_listener=0
local saw_no_windows_listener=0
local saw_cleanup_failure=0
is_wsl_environment || return 1
command -v powershell.exe >/dev/null 2>&1 || return 1
log_warn "检测到 WSL 环境,正在检查 ${service_name} 端口 ${port} 的 Windows 侧监听"
output="$(
powershell.exe -NoProfile -Command "
\$ErrorActionPreference = 'Continue'
\$port = [int]${port}
\$connections = @(Get-NetTCPConnection -LocalPort \$port -State Listen -ErrorAction SilentlyContinue)
if (-not \$connections -or \$connections.Count -eq 0) {
'no-windows-listener'
exit 0
}
\$seen = @{}
foreach (\$connection in \$connections) {
\$pidValue = [int]\$connection.OwningProcess
if (\$seen.ContainsKey(\$pidValue)) { continue }
\$seen[\$pidValue] = \$true
\$process = Get-CimInstance Win32_Process -Filter \"ProcessId=\$pidValue\" -ErrorAction SilentlyContinue
\$processName = if (\$process.Name) { \$process.Name } else { 'unknown' }
\$services = @(Get-CimInstance Win32_Service -ErrorAction SilentlyContinue | Where-Object { \$_.ProcessId -eq \$pidValue })
\$serviceNames = @(\$services | Select-Object -ExpandProperty Name)
'attempt-windows-cleanup pid={0} process={1} services={2}' -f \$pidValue, \$processName, (\$serviceNames -join ',')
foreach (\$service in \$services) {
try {
Stop-Service -Name \$service.Name -Force -ErrorAction Stop
'stopped-service {0}' -f \$service.Name
} catch {
'failed-stop-service {0}: {1}' -f \$service.Name, \$_.Exception.Message
}
}
try {
Stop-Process -Id \$pidValue -Force -ErrorAction Stop
'stopped-process {0}' -f \$pidValue
} catch {
'failed-stop-process {0}: {1}' -f \$pidValue, \$_.Exception.Message
}
}
" 2>&1 | tr -d '\r'
)"
while IFS= read -r line; do
[ -n "$line" ] || continue
case "$line" in
no-windows-listener)
saw_no_windows_listener=1
log_note "Windows 侧未发现端口 ${port} 监听,继续启动"
;;
stopped-service*|stopped-process*)
saw_windows_listener=1
printf "${GREEN} %s${NC}\n" "$line"
;;
failed-*)
saw_windows_listener=1
saw_cleanup_failure=1
printf "${DIM} %s${NC}\n" "$line"
;;
attempt-windows-cleanup*)
saw_windows_listener=1
printf "${DIM} %s${NC}\n" "$line"
;;
*)
printf "${DIM} %s${NC}\n" "$line"
;;
esac
done <<EOF
$output
EOF
if wait_for_port_release "$port" 10 0.3; then
return 0
fi
if [ "$saw_no_windows_listener" -eq 1 ] && [ "$saw_windows_listener" -eq 0 ]; then
return 0
fi
if [ "$saw_windows_listener" -eq 0 ]; then
log_warn "Windows 侧未发现端口 ${port} 监听,但端口仍不可绑定"
elif [ "$saw_cleanup_failure" -eq 1 ]; then
log_warn "Windows 侧监听清理失败,端口 ${port} 仍不可绑定"
fi
return 1
}
# Service startup helpers
wait_for_database_health() {
wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL" &&
@@ -1227,8 +1405,14 @@ cleanup_backend_processes() {
if ! wait_for_port_release "$backend_port" 15 0.2; then
terminate_backend_processes KILL "$backend_port"
wait_for_port_release "$backend_port" 15 0.2 || true
if ! wait_for_port_release "$backend_port" 15 0.2; then
if force_cleanup_external_port_listener "$backend_port" "后端"; then
return 0
fi
wait_for_port_release "$backend_port" 15 0.2 || return 1
fi
fi
return 0
}
start_backend_with_retry() {
@@ -1236,7 +1420,9 @@ start_backend_with_retry() {
local retry=1
while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do
cleanup_backend_processes "$backend_port"
if ! cleanup_backend_processes "$backend_port"; then
fail_unreleased_port "$backend_port"
fi
cd "$SCRIPT_DIR/backend"
: > /tmp/planet_backend.log
PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 &
@@ -1251,6 +1437,12 @@ start_backend_with_retry() {
kill "$BACKEND_PID" 2>/dev/null || true
if backend_log_indicates_port_conflict "/tmp/planet_backend.log"; then
cleanup_backend_processes "$backend_port" || fail_unreleased_port "$backend_port"
if wait_for_port_release "$backend_port" 5 0.2; then
log_warn "后端端口冲突已清理,立即重试启动"
retry=$((retry + 1))
continue
fi
report_backend_port_conflict_if_needed "$backend_port" "/tmp/planet_backend.log"
return 1
fi
@@ -1579,9 +1771,11 @@ kill_port_if_requested() {
fi
if [ -z "$pids" ]; then
log_warn "端口 ${port} 当前环境未发现监听进程,跳过预清理并交给${service_name}启动流程确认"
print_port_listener_details "$port"
return 0
if force_cleanup_external_port_listener "$port" "$service_name"; then
log_success "端口 ${port} 已释放"
return 0
fi
fail_unreleased_port "$port"
fi
log_step "发现端口 ${port} 占用,正在终止"
@@ -1595,8 +1789,11 @@ kill_port_if_requested() {
fi
if [ -z "$(collect_port_pids "$port" || true)" ]; then
log_warn "端口 ${port} 监听进程已清理,继续交给${service_name}启动流程确认"
return 0
if force_cleanup_external_port_listener "$port" "$service_name"; then
log_success "端口 ${port} 已释放"
return 0
fi
fail_unreleased_port "$port"
fi
log_warn "端口 ${port} 仍被占用,正在强制终止"
@@ -1611,8 +1808,11 @@ kill_port_if_requested() {
fi
if [ -z "$(collect_port_pids "$port" || true)" ]; then
log_warn "端口 ${port} 强制清理后未发现监听进程,继续交给${service_name}启动流程确认"
return 0
if force_cleanup_external_port_listener "$port" "$service_name"; then
log_success "端口 ${port} 已释放"
return 0
fi
fail_unreleased_port "$port"
fi
fail_unreleased_port "$port"
@@ -1819,12 +2019,28 @@ parse_service_args() {
BACKEND_PORT="$DEFAULT_BACKEND_PORT"
FRONTEND_PORT="$DEFAULT_FRONTEND_PORT"
AI_PROVIDER_PORT="$DEFAULT_AI_PROVIDER_PORT"
MOTION_AGENT_PORT="$DEFAULT_MOTION_AGENT_PORT"
MOTION_AGENT_CAMERA_INDEXES="${MOTION_AGENT_CAMERA_INDEXES:-}"
MOTION_AGENT_CAMERA_URLS="${MOTION_AGENT_CAMERA_URLS:-}"
BACKEND_PORT_REQUESTED=0
FRONTEND_PORT_REQUESTED=0
AI_PROVIDER_REQUESTED=0
MOTION_AGENT_REQUESTED=0
MOTION_AGENT_DRY_RUN=0
DATABASE_REQUESTED=0
FRONTEND_LAN_ENABLED=0
case "${PLANET_START_MOTION_AGENT:-0}" in
1|true|yes|on)
MOTION_AGENT_REQUESTED=1
;;
esac
case "${MOTION_AGENT_DRY_RUN:-0}" in
1|true|yes|on)
MOTION_AGENT_DRY_RUN=1
;;
esac
while [ "$#" -gt 0 ]; do
case "$1" in
-b|--backend-port)
@@ -1854,6 +2070,45 @@ parse_service_args() {
shift 1
fi
;;
-m|--motion-agent)
MOTION_AGENT_REQUESTED=1
shift 1
;;
--motion-agent-port)
MOTION_AGENT_REQUESTED=1
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
MOTION_AGENT_PORT="$2"
shift 2
else
log_error "--motion-agent-port 需要端口号"
exit 1
fi
;;
--motion-agent-dry-run)
MOTION_AGENT_REQUESTED=1
MOTION_AGENT_DRY_RUN=1
shift 1
;;
--motion-agent-camera-indexes)
MOTION_AGENT_REQUESTED=1
if [ -n "$2" ]; then
MOTION_AGENT_CAMERA_INDEXES="$2"
shift 2
else
log_error "--motion-agent-camera-indexes 需要逗号分隔的摄像头 index,例如 0,1"
exit 1
fi
;;
--motion-agent-camera-urls)
MOTION_AGENT_REQUESTED=1
if [ -n "$2" ]; then
MOTION_AGENT_CAMERA_URLS="$2"
shift 2
else
log_error "--motion-agent-camera-urls 需要逗号分隔的 RTSP/HTTP 摄像头 URL"
exit 1
fi
;;
-d|--database)
DATABASE_REQUESTED=1
shift 1
@@ -1876,6 +2131,7 @@ parse_service_args() {
validate_port "$BACKEND_PORT"
validate_port "$FRONTEND_PORT"
validate_port "$AI_PROVIDER_PORT"
validate_port "$MOTION_AGENT_PORT"
}
cleanup_exit_containers() {
@@ -1927,6 +2183,146 @@ stop_frontend_service() {
fi
}
motion_agent_pids() {
local pids=""
if [ -f "$MOTION_AGENT_PID_FILE" ]; then
pids="$(cat "$MOTION_AGENT_PID_FILE" 2>/dev/null || true)"
if [ -n "$pids" ] && kill -0 "$pids" 2>/dev/null; then
printf "%s\n" "$pids"
fi
fi
pgrep -f "python.*-m motion_agent" 2>/dev/null || true
}
cleanup_motion_agent_processes() {
local signal="${1:-TERM}"
local pid
while IFS= read -r pid; do
[ -n "$pid" ] || continue
terminate_process_group "$signal" "$pid"
terminate_process_tree "$signal" "$pid"
done < <(motion_agent_pids | awk '!seen[$0]++')
rm -f "$MOTION_AGENT_PID_FILE"
}
wait_for_motion_agent_ready() {
local port="$1"
local pid="$2"
local attempt=1
while [ "$attempt" -le 20 ]; do
if ! kill -0 "$pid" 2>/dev/null; then
return 1
fi
if ! can_bind_port "$port"; then
return 0
fi
sleep 0.2
attempt=$((attempt + 1))
done
return 1
}
start_motion_agent_service() {
local motion_agent_port="$1"
local dry_run="$2"
local lan_enabled="${3:-0}"
local bind_host="127.0.0.1"
local -a motion_args
ensure_uv_backend_deps
if [ "$dry_run" -eq 0 ] && [ -z "$MOTION_AGENT_CAMERA_URLS" ] && [ -z "$MOTION_AGENT_CAMERA_INDEXES" ]; then
MOTION_AGENT_CAMERA_INDEXES="$(detect_motion_agent_camera_indexes)"
if [ -n "$MOTION_AGENT_CAMERA_INDEXES" ]; then
log_note "Motion Agent 自动发现摄像头 index: ${MOTION_AGENT_CAMERA_INDEXES}"
else
if is_wsl_environment; then
log_warn "Motion Agent 未自动发现 /dev/video* 摄像头"
log_note "当前看起来是 WSL;Windows 摄像头通常不会自动出现在 /dev/video*。"
log_note "真实识别请选择一种方式:"
log_note " 1. 用 --motion-agent-camera-urls 接手机/RTSP/HTTP 摄像头流"
log_note " 2. 用 usbipd-win 把 USB 摄像头透传到 WSL,再重试"
log_note " 3. 仅验证 WebSocket/调试面板时,显式加 --motion-agent-dry-run"
case "${PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK:-0}" in
1|true|yes|on)
log_warn "已设置 PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1,降级为 dry-run"
dry_run=1
;;
*)
MOTION_AGENT_CAMERA_MISSING=1
;;
esac
else
log_warn "Motion Agent 未自动发现 /dev/video* 摄像头,默认尝试 index 0"
fi
fi
fi
if [ "${MOTION_AGENT_CAMERA_MISSING:-0}" -eq 1 ]; then
log_error "Motion Agent live 模式缺少可用摄像头"
log_note "WSL 示例:./planet.sh restart -m --motion-agent-camera-urls http://<手机IP>:8080/video"
log_note "或仅调试协议:./planet.sh restart -m --motion-agent-dry-run"
exit 1
fi
if [ "$dry_run" -eq 0 ]; then
ensure_motion_agent_live_deps
fi
cleanup_motion_agent_processes TERM
wait_for_port_release "$motion_agent_port" 10 0.2 || true
if ! can_bind_port "$motion_agent_port"; then
log_error "Motion Agent 端口已被占用: ${motion_agent_port}"
print_port_listener_details "$motion_agent_port"
exit 1
fi
: > "$MOTION_AGENT_LOG_FILE"
if [ "$lan_enabled" -eq 1 ]; then
bind_host="0.0.0.0"
fi
motion_args=(-m motion_agent --host "$bind_host" --port "$motion_agent_port")
if [ -n "$MOTION_AGENT_CAMERA_URLS" ]; then
motion_args+=(--camera-urls "$MOTION_AGENT_CAMERA_URLS")
elif [ -n "$MOTION_AGENT_CAMERA_INDEXES" ]; then
motion_args+=(--camera-indexes "$MOTION_AGENT_CAMERA_INDEXES")
fi
if [ "$dry_run" -eq 1 ]; then
motion_args+=(--dry-run)
fi
cd "$SCRIPT_DIR"
nohup "$SCRIPT_DIR/.venv/bin/python" "${motion_args[@]}" > "$MOTION_AGENT_LOG_FILE" 2>&1 &
MOTION_AGENT_PID=$!
printf "%s" "$MOTION_AGENT_PID" > "$MOTION_AGENT_PID_FILE"
if wait_for_motion_agent_ready "$motion_agent_port" "$MOTION_AGENT_PID"; then
log_success "Motion Agent 已就绪"
log_motion_agent_access_notes "$motion_agent_port" "$lan_enabled"
return 0
fi
log_error "Motion Agent 启动失败"
tail -20 "$MOTION_AGENT_LOG_FILE" 2>/dev/null || true
log_note "如只需验证 Web 端连接,可加 --motion-agent-dry-run。"
log_note "如需真实摄像头识别,请确认系统存在 /dev/video*;脚本会自动发现,也可用 --motion-agent-camera-indexes 1,2 覆盖。"
log_note "WSL 下也可用 --motion-agent-camera-urls rtsp://... 或 http://... 接入手机/网络摄像头。"
log_note "依赖缺失时脚本会自动执行 uv add mediapipe opencv-python。"
cleanup_motion_agent_processes TERM
exit 1
}
stop_motion_agent_service() {
if [ -f "$MOTION_AGENT_PID_FILE" ] || pgrep -f "python.*-m motion_agent" >/dev/null 2>&1; then
cleanup_motion_agent_processes TERM
log_halt "Motion Agent 已停止"
fi
}
create_user() {
local username
local password
@@ -2041,12 +2437,18 @@ start() {
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED"
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED"
fi
log_success "启动完成"
log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth"
log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin"
log_note "AI Playground: http://localhost:${FRONTEND_PORT}/playground"
log_note "智能星球开发文档: http://localhost:${BACKEND_PORT}/docs"
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
log_motion_agent_access_notes "$MOTION_AGENT_PORT" "$FRONTEND_LAN_ENABLED"
fi
if [ "$FRONTEND_LAN_ENABLED" -eq 1 ]; then
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
fi
@@ -2057,6 +2459,7 @@ stop() {
stop_backend_service
stop_ai_provider_service
stop_frontend_service
stop_motion_agent_service
stop_container_if_running "planet_postgres" "PostgreSQL"
stop_container_if_running "planet_redis" "Redis"
log_success "已停止"
@@ -2066,7 +2469,7 @@ restart() {
parse_service_args "$@"
cleanup_exit_containers
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$MOTION_AGENT_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
stop
sleep 1
start "$@"
@@ -2097,6 +2500,12 @@ restart() {
start_frontend_service "$FRONTEND_PORT" 1 "$FRONTEND_LAN_ENABLED"
fi
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
stop_motion_agent_service
sleep 1
start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED"
fi
echo ""
log_success "重启完成"
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
@@ -2114,6 +2523,9 @@ restart() {
log_lan_access_notes "$FRONTEND_PORT" "$BACKEND_PORT"
fi
fi
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
log_motion_agent_access_notes "$MOTION_AGENT_PORT" "$FRONTEND_LAN_ENABLED"
fi
}
health() {
@@ -2139,6 +2551,12 @@ health() {
else
echo -e "${DIM} 前端:${NC} ${RED}offline${NC}"
fi
if pgrep -f "python.*-m motion_agent" >/dev/null 2>&1 || [ -f "$MOTION_AGENT_PID_FILE" ]; then
echo -e "${DIM} Motion Agent:${NC} ${GREEN}online${NC}"
else
echo -e "${DIM} Motion Agent:${NC} ${RED}offline${NC}"
fi
}
log() {
@@ -2158,6 +2576,11 @@ log() {
log_note "按 Ctrl+C 退出"
docker logs -f planet_aiprovider
;;
-m|--motion-agent)
log_step "查看 Motion Agent 日志"
log_note "按 Ctrl+C 退出"
tail -f "$MOTION_AGENT_LOG_FILE"
;;
*)
log_step "查看最近日志"
log_note "后端"
@@ -2166,6 +2589,8 @@ log() {
docker logs --tail 20 planet_aiprovider 2>/dev/null || log_note "无日志"
log_note "前端"
tail -20 /tmp/planet_frontend.log 2>/dev/null || log_note "无日志"
log_note "Motion Agent"
tail -20 "$MOTION_AGENT_LOG_FILE" 2>/dev/null || log_note "无日志"
;;
esac
}
@@ -2213,14 +2638,15 @@ case "$1" in
*)
log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
log_note "全局参数: -v, --verbose 在当前执行行下方滚动显示最多 5 行命令输出"
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> --allow-lan --verbose"
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> -m/--motion-agent --motion-agent-port <端口> --motion-agent-camera-indexes 0,1 --motion-agent-camera-urls rtsp://... --motion-agent-dry-run --allow-lan --verbose"
log_note "stop 停止服务"
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d --allow-lan --verbose"
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -m [Motion Agent] -d --allow-lan --verbose"
log_note "createuser 交互创建用户"
log_note "health 检查健康状态"
log_note "log 查看日志"
log_note "log -f 查看前端日志"
log_note "log -b 查看后端日志"
log_note "log -a 查看 AI Provider 日志"
log_note "log -m 查看 Motion Agent 日志"
;;
esac

View File

@@ -1,6 +1,6 @@
[project]
name = "planet"
version = "0.49.0"
version = "0.50.0"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [
@@ -23,6 +23,8 @@ dependencies = [
"email-validator>=2.1.0",
"apscheduler>=3.10.4",
"networkx>=3.0",
"mediapipe>=0.10.35",
"opencv-python>=4.13.0.92",
]
[tool.uv]

316
uv.lock generated
View File

@@ -2,6 +2,15 @@ version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "absl-py"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" },
]
[[package]]
name = "aiofiles"
version = "25.1.0"
@@ -226,6 +235,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "contourpy"
version = "1.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" },
{ url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" },
{ url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" },
{ url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" },
{ url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" },
{ url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" },
{ url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" },
{ url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" },
{ url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" },
{ url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" },
{ url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" },
{ url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" },
{ url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" },
{ url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" },
{ url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" },
{ url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" },
{ url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" },
{ url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" },
{ url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
]
[[package]]
name = "cryptography"
version = "46.0.4"
@@ -279,6 +321,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" },
]
[[package]]
name = "cycler"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
@@ -328,6 +379,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
]
[[package]]
name = "flatbuffers"
version = "25.12.19"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" },
]
[[package]]
name = "fonttools"
version = "4.62.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" },
{ url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" },
{ url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" },
{ url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" },
{ url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" },
{ url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" },
{ url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" },
{ url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" },
{ url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" },
{ url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" },
{ url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" },
{ url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" },
{ url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" },
{ url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" },
{ url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" },
{ url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" },
]
[[package]]
name = "greenlet"
version = "3.3.1"
@@ -423,6 +507,97 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "kiwisolver"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" },
{ url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" },
{ url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" },
{ url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" },
{ url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" },
{ url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" },
{ url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" },
{ url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" },
{ url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" },
{ url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" },
{ url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" },
{ url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" },
{ url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" },
{ url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" },
{ url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" },
{ url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" },
{ url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" },
{ url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" },
{ url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" },
{ url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" },
{ url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" },
{ url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" },
{ url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" },
{ url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" },
{ url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" },
{ url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" },
]
[[package]]
name = "matplotlib"
version = "3.10.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "contourpy" },
{ name = "cycler" },
{ name = "fonttools" },
{ name = "kiwisolver" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pillow" },
{ name = "pyparsing" },
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" },
{ url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" },
{ url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" },
{ url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" },
{ url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" },
{ url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" },
{ url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" },
{ url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" },
{ url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" },
{ url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" },
{ url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" },
{ url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" },
{ url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" },
]
[[package]]
name = "mediapipe"
version = "0.10.35"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "absl-py" },
{ name = "certifi" },
{ name = "flatbuffers" },
{ name = "matplotlib" },
{ name = "numpy" },
{ name = "opencv-contrib-python" },
{ name = "sounddevice" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/c2/439e948d2a9a542498aee5d8fa3fb91ac6f4478be5d502f0c78ca3fb2333/mediapipe-0.10.35-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3b31376f34ca3665e34b834565996464cd66c9c91316e914fa7f149c891ce7ac", size = 17782605, upload-time = "2026-04-27T17:45:29.457Z" },
{ url = "https://files.pythonhosted.org/packages/32/8f/1bc57dbc9b7b03c8f875aac23380ec57e9002cc02fe6720045fb263f3966/mediapipe-0.10.35-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:db9a579df48cffe9570cd3e93f6a5d2dd089a1103b846c60c5b5de8a21c38db0", size = 12400490, upload-time = "2026-04-27T17:45:31.848Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f6/763477e9aeed98accc984ed6ee3f11a21a0c5fd1d1c6586b8d07067748ff/mediapipe-0.10.35-py3-none-win_amd64.whl", hash = "sha256:b08f001cf3c3cd0d88d9ed68f3368dc8a4913f568281a93117f083115aa672ba", size = 10905503, upload-time = "2026-04-27T17:45:33.946Z" },
{ url = "https://files.pythonhosted.org/packages/07/b3/5c7fa594c731e8dafab9f1a46ab6cef670fa62dbbfb6248cc70e42ec6fc5/mediapipe-0.10.35-py3-none-win_arm64.whl", hash = "sha256:46255326a6213118aaa518a7aa25e35f93337e82677960cc2a945f117bff8444", size = 9992331, upload-time = "2026-04-27T17:45:36.193Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
@@ -441,6 +616,71 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
]
[[package]]
name = "numpy"
version = "2.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" },
{ url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" },
{ url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" },
{ url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" },
{ url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" },
{ url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" },
{ url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" },
{ url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" },
{ url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" },
{ url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" },
{ url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" },
{ url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" },
{ url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" },
{ url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" },
{ url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" },
{ url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" },
{ url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" },
{ url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" },
{ url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" },
]
[[package]]
name = "opencv-contrib-python"
version = "4.13.0.92"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/4c/a45c96b9fe90b2c48ee604f5176eb7deb46ce7c2e87c8d819d2945dbcab6/opencv_contrib_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:53c8ab81376210dda5836307eb6bda7266f39a3820a9a070c7131510ba815fe1", size = 52041546, upload-time = "2026-02-05T07:01:29.918Z" },
{ url = "https://files.pythonhosted.org/packages/8b/6c/ba1f3177927deeb3002b62fb8db89daea3b5dc732d61de5bf4c73ed6ebf7/opencv_contrib_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:1973d0fc773873f9d1b5bf0d1b65895da2f47b06ba033b7d58393f5c28ba0778", size = 38830319, upload-time = "2026-02-05T07:01:47.222Z" },
{ url = "https://files.pythonhosted.org/packages/ff/7a/fe87eaf109b454af4a2579f46958b3cafb0f804b9c788c108760723a9bb7/opencv_contrib_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f9cb522dd9e465dfca3536c15288f7936b9827432fb9c885eaf94dc5f88c2a3", size = 53339457, upload-time = "2026-02-05T10:09:02.332Z" },
{ url = "https://files.pythonhosted.org/packages/b6/27/3665ca4b75ddfd218f9ab139f0463d9571e87aaf59391d3c4f5546c08df7/opencv_contrib_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f9ceec5886419860a31b518991a99e978e5a6a78dca1470103ad4ede0155f156", size = 76591184, upload-time = "2026-02-05T10:11:51.298Z" },
{ url = "https://files.pythonhosted.org/packages/f3/11/10c46e9527c4591d5264117debd8fe0e21bb23dbf378ce760add6b1e85b6/opencv_contrib_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a3c54377c5cf9c45d9b1a207df26dc8fe4f1042d07036cb17d80930c04b25d97", size = 52544155, upload-time = "2026-02-05T10:13:32.068Z" },
{ url = "https://files.pythonhosted.org/packages/ba/f6/3c645c21358079097201090de7c30d110f5ec3fa01008e3ee81b0a77a354/opencv_contrib_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:fc5ee50e2be9d40e913536f7f20cc6f87f25d8e413ebb32a3335ab6edf245d3e", size = 79150872, upload-time = "2026-02-05T10:16:03.465Z" },
{ url = "https://files.pythonhosted.org/packages/90/d7/bf4622e0ed8a93f5a685c76933e287477cf185a160c66478cf144fece489/opencv_contrib_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:f5d02357f4d5575c300eab3ec1c7ecfed3a9a53e55a76927bab7cfc9e0a67b68", size = 36829959, upload-time = "2026-02-05T07:02:22.25Z" },
{ url = "https://files.pythonhosted.org/packages/d9/98/a03f69ff6fb86a67d584ecc990d85a95e6930b96e3f39ad1f8e019cb8ada/opencv_contrib_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:cb694dcf76bb2c8d7fa573fc1a99339e8b6640194d7778381e74cc3445369e45", size = 46486178, upload-time = "2026-02-05T07:02:19.551Z" },
]
[[package]]
name = "opencv-python"
version = "4.13.0.92"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" },
{ url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" },
{ url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" },
{ url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" },
{ url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" },
{ url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" },
{ url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
]
[[package]]
name = "packaging"
version = "26.0"
@@ -473,9 +713,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
]
[[package]]
name = "planet"
version = "0.49.0"
version = "0.50.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },
@@ -486,7 +759,9 @@ dependencies = [
{ name = "email-validator" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "mediapipe" },
{ name = "networkx" },
{ name = "opencv-python" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "pydantic" },
{ name = "pydantic-settings" },
@@ -517,7 +792,9 @@ requires-dist = [
{ name = "email-validator", specifier = ">=2.1.0" },
{ name = "fastapi", specifier = ">=0.109.0" },
{ name = "httpx", specifier = ">=0.26.0" },
{ name = "mediapipe", specifier = ">=0.10.35" },
{ name = "networkx", specifier = ">=3.0" },
{ name = "opencv-python", specifier = ">=4.13.0.92" },
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
{ name = "pydantic", specifier = ">=2.5.0" },
{ name = "pydantic-settings", specifier = ">=2.1.0" },
@@ -651,6 +928,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
@@ -679,6 +965,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
@@ -816,6 +1114,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sounddevice"
version = "0.5.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" },
{ url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" },
{ url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" },
{ url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" },
{ url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" },
]
[[package]]
name = "soupsieve"
version = "2.8.3"