forked from linkong/planet
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cee1996809 | ||
|
|
58671e7bc3 |
@@ -324,7 +324,7 @@ ipconfig
|
||||
|
||||
## 启动容错参数
|
||||
|
||||
`planet.sh` 现在为依赖安装、数据库、AI Provider 启动加入了有限次重试,并会在数据库与 `aiprovider` 启动后额外等待 Docker healthcheck。
|
||||
`planet.sh` 为依赖安装、数据库、AI Provider 启动提供有限次重试。数据库先检查容器健康,再验证后端实际连接;`aiprovider` 直接以宿主机 `/health` 就绪为准。后端进程退出或应用初始化失败时立即停止等待,避免重复消耗健康检查预算。
|
||||
|
||||
可通过环境变量临时调整:
|
||||
|
||||
|
||||
@@ -7,9 +7,6 @@ ARG AI_PROVIDER_BUILD_FINGERPRINT=unknown
|
||||
FROM ${UV_IMAGE} AS uv
|
||||
FROM ${PYTHON_IMAGE}
|
||||
|
||||
ARG AI_PROVIDER_BUILD_FINGERPRINT
|
||||
LABEL planet.aiprovider.build-fingerprint="${AI_PROVIDER_BUILD_FINGERPRINT}"
|
||||
|
||||
COPY --from=uv /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
@@ -28,13 +25,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
COPY pyproject.toml uv.lock /app/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=secret,id=planet_uv_config,target=/root/.config/uv/uv.toml,required=false \
|
||||
uv sync --frozen --no-dev
|
||||
uv sync --frozen --only-group aiprovider
|
||||
|
||||
COPY aiprovider /app/aiprovider
|
||||
|
||||
ARG AI_PROVIDER_BUILD_FINGERPRINT
|
||||
LABEL planet.aiprovider.build-fingerprint="${AI_PROVIDER_BUILD_FINGERPRINT}"
|
||||
|
||||
EXPOSE 8010
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:8010/health >/dev/null || exit 1
|
||||
|
||||
CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "aiprovider.main:app", "--host", "0.0.0.0", "--port", "8010"]
|
||||
CMD ["/app/.venv/bin/python", "-m", "uvicorn", "aiprovider.main:app", "--host", "0.0.0.0", "--port", "8010"]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
完整使用说明见:
|
||||
|
||||
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
||||
- [AI Provider 指南](../docs/technical/zh/agents-aiprovider.md)
|
||||
|
||||
当前支持:
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
- `AI_PROVIDER=ollama`
|
||||
- request adapter:
|
||||
- `AI_PROVIDER_API=openai-completions`
|
||||
- `AI_PROVIDER_API=openai-responses`
|
||||
- `AI_PROVIDER_API=anthropic-messages`
|
||||
- `AI_PROVIDER_API=ollama-generate`
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
@@ -65,6 +66,7 @@ class ProviderService:
|
||||
self.model_provider_apis = self._parse_model_provider_apis(
|
||||
overrides.get("model_provider_apis")
|
||||
)
|
||||
self.session_id = str(uuid4())
|
||||
|
||||
def get_status(self) -> AIProviderStatusResponse:
|
||||
enabled = self.provider != "disabled"
|
||||
@@ -95,6 +97,8 @@ class ProviderService:
|
||||
)
|
||||
|
||||
prompt = self._build_prompt(payload)
|
||||
if payload.context.get("session_id") is not None:
|
||||
self.session_id = str(uuid5(NAMESPACE_URL, f"planet:{payload.context['session_id']}"))
|
||||
|
||||
provider_api = self._resolve_model_provider_api(model)
|
||||
|
||||
@@ -102,6 +106,10 @@ class ProviderService:
|
||||
data = await self._request_openai_compatible(model, prompt, payload.system_prompt)
|
||||
content = self._extract_openai_content(data)
|
||||
content_blocks = self._extract_openai_blocks(data)
|
||||
elif provider_api == "openai-responses":
|
||||
data = await self._request_openai_responses(model, prompt, payload.system_prompt)
|
||||
content_blocks = self._extract_responses_blocks(data)
|
||||
content = "".join(block.text for block in content_blocks if block.text)
|
||||
elif provider_api == "anthropic-messages":
|
||||
data = await self._request_anthropic_messages(
|
||||
model,
|
||||
@@ -200,6 +208,39 @@ class ProviderService:
|
||||
request_body=request_body,
|
||||
)
|
||||
|
||||
async def _request_openai_responses(
|
||||
self, model: str, prompt: str, system_prompt: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
request_body: dict[str, Any] = {
|
||||
"model": model, "input": prompt, "max_output_tokens": self.max_tokens, "store": False,
|
||||
}
|
||||
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
|
||||
if resolved_system_prompt:
|
||||
request_body["instructions"] = resolved_system_prompt
|
||||
return await self._post(
|
||||
path="/responses",
|
||||
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
|
||||
request_body=request_body,
|
||||
)
|
||||
|
||||
def _extract_responses_blocks(self, payload: dict[str, Any]) -> list[AIContentBlock]:
|
||||
blocks: list[AIContentBlock] = []
|
||||
for item in payload.get("output") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("type") == "message":
|
||||
for part in item.get("content") or []:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
text = part.get("text") or part.get("refusal")
|
||||
if isinstance(text, str) and text:
|
||||
blocks.append(AIContentBlock(type="text", text=text))
|
||||
elif item.get("type") == "reasoning":
|
||||
for part in item.get("summary") or []:
|
||||
if isinstance(part, dict) and isinstance(part.get("text"), str):
|
||||
blocks.append(AIContentBlock(type="thinking", thinking=part["text"]))
|
||||
return blocks
|
||||
|
||||
async def _request_anthropic_messages(
|
||||
self,
|
||||
model: str,
|
||||
@@ -226,7 +267,7 @@ class ProviderService:
|
||||
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
|
||||
if resolved_system_prompt:
|
||||
request_body["system"] = resolved_system_prompt
|
||||
resolved_thinking = self._resolve_anthropic_thinking(thinking)
|
||||
resolved_thinking = self._resolve_anthropic_thinking(thinking, model)
|
||||
if resolved_thinking:
|
||||
request_body["thinking"] = resolved_thinking
|
||||
if self.provider == "minimax" and self.base_url.endswith("/anthropic"):
|
||||
@@ -243,8 +284,12 @@ class ProviderService:
|
||||
request_body=request_body,
|
||||
)
|
||||
|
||||
def _resolve_anthropic_thinking(self, thinking: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
def _resolve_anthropic_thinking(
|
||||
self, thinking: dict[str, Any] | None, model: str,
|
||||
) -> dict[str, Any] | None:
|
||||
if thinking:
|
||||
if model.casefold() == "minimax-m3" and thinking.get("type") == "enabled":
|
||||
return {"type": "adaptive"}
|
||||
return thinking
|
||||
|
||||
# OpenClaw treats MiniMax's Anthropic-compatible path specially:
|
||||
@@ -295,6 +340,9 @@ class ProviderService:
|
||||
headers: dict[str, str],
|
||||
request_body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
headers = {"User-Agent": "Planet/1.0", **headers}
|
||||
if self.provider == "opencode-go":
|
||||
headers["x-opencode-session"] = self.session_id
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, self.http_retry_attempts + 1):
|
||||
try:
|
||||
|
||||
@@ -3,9 +3,9 @@ from datetime import UTC, datetime
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
import httpx
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from dotenv import dotenv_values
|
||||
from sqlalchemy import select
|
||||
@@ -65,6 +65,7 @@ from app.services.llm_provider_catalog import (
|
||||
list_fallback_llm_provider_presets,
|
||||
refresh_llm_provider_preset,
|
||||
)
|
||||
from app.services.llm_model_catalog import catalog_error_message, fetch_model_catalog
|
||||
from app.services.scheduler import sync_datasource_job
|
||||
from app.services.tv_streams import DEFAULT_TV_SETTINGS, get_tv_settings_payload, normalize_tv_settings
|
||||
from app.services.persistent_logs import record_audit_log
|
||||
@@ -75,6 +76,7 @@ logger = get_logger(__name__, service="api")
|
||||
AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS = 5
|
||||
AI_CONNECTION_TEST_PROMPT_KEY = "ai.connection_test"
|
||||
SECRET_REVEAL_ROLES = {UserRole.ADMIN.value, UserRole.SUPER_ADMIN.value}
|
||||
LLM_PROVIDER_PRESET_CATEGORY_PREFIX = "llm_provider_preset:"
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"system": {
|
||||
@@ -345,7 +347,13 @@ class ExternalIntegrationsUpdate(BaseModel):
|
||||
|
||||
|
||||
def merge_with_defaults(category: str, payload: Optional[dict]) -> dict:
|
||||
merged = deepcopy(DEFAULT_SETTINGS[category])
|
||||
if category.startswith(LLM_PROVIDER_PRESET_CATEGORY_PREFIX):
|
||||
defaults = get_fallback_llm_provider_preset(
|
||||
category.removeprefix(LLM_PROVIDER_PRESET_CATEGORY_PREFIX)
|
||||
)
|
||||
else:
|
||||
defaults = DEFAULT_SETTINGS[category]
|
||||
merged = deepcopy(defaults)
|
||||
if payload:
|
||||
merged.update(payload)
|
||||
return merged
|
||||
@@ -382,6 +390,7 @@ async def get_setting_payload(db: AsyncSession, category: str) -> dict:
|
||||
|
||||
|
||||
async def save_setting_payload(db: AsyncSession, category: str, payload: dict) -> dict:
|
||||
merged = merge_with_defaults(category, payload)
|
||||
record = await get_setting_record(db, category)
|
||||
if record is None:
|
||||
record = SystemSetting(category=category, payload=payload)
|
||||
@@ -391,7 +400,7 @@ async def save_setting_payload(db: AsyncSession, category: str, payload: dict) -
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return merge_with_defaults(category, record.payload)
|
||||
return merged
|
||||
|
||||
|
||||
AI_PROVIDER_ENV_FILE = Path(__file__).resolve().parents[4] / "aiprovider" / ".env"
|
||||
@@ -655,6 +664,12 @@ def _runtime_config_from_ai_payload(ai_payload: dict) -> dict:
|
||||
normalized_ai["providers"].get(default_provider) or _provider_defaults(default_provider)
|
||||
)
|
||||
api_key, _api_key_source = _resolve_provider_api_key(default_provider, provider_config)
|
||||
model_provider_apis = {
|
||||
**(provider_config.get("model_provider_apis") or {}),
|
||||
**(_get_provider_preset(default_provider).get("model_provider_apis") or {}),
|
||||
}
|
||||
if default_provider == "openai" and urlsplit(provider_config.get("base_url") or "").hostname != "api.openai.com":
|
||||
model_provider_apis = {}
|
||||
return {
|
||||
"service_url": normalized_ai.get("service_url") or app_settings.AI_PROVIDER_SERVICE_URL,
|
||||
"service_token": _resolve_service_token(normalized_ai)[0],
|
||||
@@ -672,8 +687,7 @@ def _runtime_config_from_ai_payload(ai_payload: dict) -> dict:
|
||||
"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",
|
||||
"model_provider_apis": provider_config.get("model_provider_apis") or {},
|
||||
"preset_models": _get_provider_preset(default_provider).get("models") or [],
|
||||
"model_provider_apis": model_provider_apis,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -753,159 +767,35 @@ async def _validate_ai_provider_full_connection(ai_payload: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _join_provider_url(base_url: str, path: str) -> str:
|
||||
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def _extract_model_ids(payload: dict) -> list[str]:
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
if isinstance(data, list):
|
||||
return [
|
||||
str(item.get("id"))
|
||||
for item in data
|
||||
if isinstance(item, dict) and item.get("id")
|
||||
]
|
||||
models = payload.get("models") if isinstance(payload, dict) else None
|
||||
if isinstance(models, list):
|
||||
return [
|
||||
str(item.get("name") or item.get("model") or item.get("id") or item)
|
||||
for item in models
|
||||
if item
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _contains_model(model_ids: list[str], model: str) -> bool:
|
||||
normalized_model = model.strip().lower()
|
||||
return any(str(item).strip().lower() == normalized_model for item in model_ids)
|
||||
|
||||
|
||||
async def _check_ai_provider_lightweight(llm_config: dict, timeout_seconds: int) -> dict:
|
||||
provider = _normalize_provider_id(llm_config.get("provider") or "")
|
||||
configured_api = (
|
||||
str(llm_config.get("provider_api") or "").strip()
|
||||
or ProviderApi.OPENAI_COMPLETIONS.value
|
||||
)
|
||||
provider_api = str(llm_config.get("provider_api") or ProviderApi.OPENAI_COMPLETIONS.value)
|
||||
model = str(llm_config.get("model") or "").strip()
|
||||
base_url = str(llm_config.get("base_url") or "").strip().rstrip("/")
|
||||
base_url = str(llm_config.get("base_url") or "").strip()
|
||||
api_key = str(llm_config.get("api_key") or "").strip()
|
||||
provider_api = configured_api
|
||||
model_provider_apis = llm_config.get("model_provider_apis")
|
||||
if isinstance(model_provider_apis, dict):
|
||||
provider_api = str(model_provider_apis.get(model) or provider_api)
|
||||
preset_models = [
|
||||
str(item)
|
||||
for item in (llm_config.get("preset_models") or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
|
||||
if not provider or not base_url or not model:
|
||||
return {
|
||||
"success": False,
|
||||
"connected": False,
|
||||
"message": "当前 provider/base_url/model 未完整配置。",
|
||||
"mode": "lightweight_config",
|
||||
}
|
||||
result = {
|
||||
"success": False, "connected": False, "mode": "lightweight_models",
|
||||
"provider": provider, "model": model,
|
||||
}
|
||||
if not base_url or not model:
|
||||
return {**result, "message": "当前 provider/base_url/model 未完整配置。"}
|
||||
if provider_api != ProviderApi.OLLAMA_GENERATE.value and not api_key:
|
||||
return {
|
||||
"success": False,
|
||||
"connected": False,
|
||||
"message": "当前 provider 未配置 API Key。",
|
||||
"mode": "lightweight_config",
|
||||
}
|
||||
|
||||
if provider == "opencode-go":
|
||||
url = _join_provider_url(base_url, "/models")
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
elif provider_api == ProviderApi.OLLAMA_GENERATE.value:
|
||||
url = _join_provider_url(base_url, "/api/tags")
|
||||
headers: dict[str, str] = {}
|
||||
elif provider_api == ProviderApi.OPENAI_COMPLETIONS.value:
|
||||
url = _join_provider_url(base_url, "/models")
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
elif provider_api == ProviderApi.ANTHROPIC_MESSAGES.value:
|
||||
url = _join_provider_url(base_url, "/models")
|
||||
headers = {
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": str(llm_config.get("anthropic_version") or "2023-06-01"),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"connected": False,
|
||||
"message": f"当前 provider_api 不支持轻量连通性测试: {provider_api}",
|
||||
"mode": "lightweight_unsupported",
|
||||
}
|
||||
|
||||
return {**result, "message": "当前 provider 未配置 API Key。"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=min(timeout_seconds, AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS)) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = exc.response.text or exc.response.reason_phrase
|
||||
if exc.response.status_code == 404 and _contains_model(preset_models, model):
|
||||
return {
|
||||
"success": True,
|
||||
"connected": True,
|
||||
"message": "轻量连通性测试通过;当前 provider 不提供可用的模型目录,已按内置模型预设确认。",
|
||||
"mode": "lightweight_preset",
|
||||
"provider": provider,
|
||||
"provider_api": provider_api,
|
||||
"model": model,
|
||||
"url": url,
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"connected": False,
|
||||
"message": f"轻量连通性测试失败: HTTP {exc.response.status_code} {detail}",
|
||||
"mode": "lightweight_models",
|
||||
"url": url,
|
||||
}
|
||||
catalog = await fetch_model_catalog(
|
||||
provider, base_url, provider_api, api_key,
|
||||
str(llm_config.get("anthropic_version") or "2023-06-01"), timeout_seconds,
|
||||
)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"connected": False,
|
||||
"message": f"轻量连通性测试失败: {exc}",
|
||||
"mode": "lightweight_models",
|
||||
"url": url,
|
||||
}
|
||||
|
||||
model_ids = _extract_model_ids(payload)
|
||||
if model_ids and not _contains_model(model_ids, model):
|
||||
if _contains_model(preset_models, model):
|
||||
return {
|
||||
"success": True,
|
||||
"connected": True,
|
||||
"message": "轻量连通性测试通过;provider 模型目录未返回当前别名,已按内置模型预设确认。",
|
||||
"mode": "lightweight_models_with_preset_alias",
|
||||
"provider": provider,
|
||||
"provider_api": provider_api,
|
||||
"model": model,
|
||||
"models_count": len(model_ids),
|
||||
"url": url,
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"connected": False,
|
||||
"message": f"连接可用,但模型目录中没有当前模型: {model}",
|
||||
"mode": "lightweight_models",
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"models_count": len(model_ids),
|
||||
"url": url,
|
||||
}
|
||||
|
||||
return {**result, "message": catalog_error_message(exc)}
|
||||
result.update({"url": catalog.url, "models_count": len(catalog.models)})
|
||||
if model.casefold() not in {item.casefold() for item in catalog.models}:
|
||||
return {**result, "message": f"模型目录查询成功,但当前模型不在目录中:{model}"}
|
||||
model_apis = llm_config.get("model_provider_apis") or {}
|
||||
return {
|
||||
"success": True,
|
||||
"connected": True,
|
||||
"message": "轻量连通性测试通过",
|
||||
"mode": "lightweight_models",
|
||||
"provider": provider,
|
||||
"provider_api": provider_api,
|
||||
"model": model,
|
||||
"models_count": len(model_ids),
|
||||
"url": url,
|
||||
**result, "success": True, "connected": True,
|
||||
"provider_api": model_apis.get(model) or provider_api,
|
||||
"message": "模型目录查询成功,当前模型已找到;尚未执行生成调用。",
|
||||
}
|
||||
|
||||
|
||||
@@ -1689,7 +1579,8 @@ async def connect_ai_provider_integration(
|
||||
)
|
||||
await emit_business_log(
|
||||
logger,
|
||||
event="settings.ai_provider.connect.success",
|
||||
event=("settings.ai_provider.connect.success" if lightweight_result["success"]
|
||||
else "settings.ai_provider.connect.failed"),
|
||||
message="AI provider connection test completed",
|
||||
category="ai",
|
||||
service="api",
|
||||
@@ -1699,7 +1590,7 @@ async def connect_ai_provider_integration(
|
||||
"provider": payload.provider,
|
||||
"model": payload.model,
|
||||
"configured": True,
|
||||
"lightweight_status": lightweight_result.get("status"),
|
||||
"connected": lightweight_result["connected"],
|
||||
},
|
||||
)
|
||||
return {
|
||||
@@ -2044,8 +1935,18 @@ async def reset_provider_credential_guide(
|
||||
@router.get("/integrations/ai-provider/presets")
|
||||
async def get_ai_provider_presets(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return {"data": list_fallback_llm_provider_presets()}
|
||||
presets = list_fallback_llm_provider_presets()
|
||||
saved = await get_setting_payloads(
|
||||
db, [f"{LLM_PROVIDER_PRESET_CATEGORY_PREFIX}{preset['provider']}" for preset in presets]
|
||||
)
|
||||
return {
|
||||
"data": [
|
||||
{**preset, **saved[f"{LLM_PROVIDER_PRESET_CATEGORY_PREFIX}{preset['provider']}"]}
|
||||
for preset in presets
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/integrations/ai-provider/presets/{provider}/refresh")
|
||||
@@ -2053,22 +1954,52 @@ async def refresh_ai_provider_preset(
|
||||
provider: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
payload: AIProviderIntegrationUpdate | None = None,
|
||||
):
|
||||
try:
|
||||
provider_id = _normalize_provider_id(provider)
|
||||
api_key = None
|
||||
if provider_id == "opencode-go":
|
||||
current_payload = await get_setting_payload(db, "external_integrations")
|
||||
ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
|
||||
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)
|
||||
return {"data": await refresh_llm_provider_preset(provider_id, api_key=api_key)}
|
||||
get_fallback_llm_provider_preset(provider_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
current_payload = await get_setting_payload(db, "external_integrations")
|
||||
if payload is not None:
|
||||
if _normalize_provider_id(payload.provider) != provider_id:
|
||||
raise HTTPException(status_code=400, detail="刷新供应商与表单供应商不一致。")
|
||||
ai_payload = _build_ai_provider_payload(current_payload, payload)
|
||||
else:
|
||||
ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
|
||||
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)
|
||||
if not api_key and provider_id not in {"ollama", "opencode-go", "openrouter"}:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="请先配置当前供应商的 API Key,再刷新模型列表。",
|
||||
)
|
||||
try:
|
||||
refreshed = await refresh_llm_provider_preset(
|
||||
provider_id, api_key=api_key, base_url=provider_config.get("base_url"),
|
||||
provider_api=provider_config.get("provider_api"),
|
||||
anthropic_version=provider_config.get("anthropic_version") or "2023-06-01",
|
||||
)
|
||||
except Exception as exc:
|
||||
fallback = get_fallback_llm_provider_preset(provider)
|
||||
fallback["refresh_error"] = str(exc)
|
||||
return {"data": fallback}
|
||||
logger.warning(
|
||||
"LLM provider catalog refresh failed",
|
||||
extra={
|
||||
"event": "settings.ai_provider.catalog.failed",
|
||||
"context": {
|
||||
"provider": provider_id,
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"{catalog_error_message(exc)} 已保留上次模型列表。",
|
||||
) from exc
|
||||
|
||||
refreshed["refreshed_at"] = to_iso8601_utc(datetime.now(UTC))
|
||||
await save_setting_payload(db, f"{LLM_PROVIDER_PRESET_CATEGORY_PREFIX}{provider_id}", refreshed)
|
||||
return {"data": refreshed}
|
||||
|
||||
|
||||
@router.put("/integrations")
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.services.tv_catalog import get_tv_catalog_page
|
||||
from app.services.tv_streams import get_public_tv_payload, is_allowed_tv_proxy_url
|
||||
|
||||
router = APIRouter()
|
||||
@@ -34,9 +35,13 @@ def _should_strip_hls_metadata_line(line: str) -> bool:
|
||||
|
||||
@router.get("/streams")
|
||||
async def list_public_tv_streams(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
q: str = Query("", max_length=200),
|
||||
selected_id: str | None = Query(None, max_length=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await get_public_tv_payload(db)
|
||||
return await get_tv_catalog_page(db, offset=offset, limit=limit, q=q, selected_id=selected_id)
|
||||
|
||||
|
||||
@router.get("/proxy")
|
||||
|
||||
@@ -1121,6 +1121,7 @@ async def build_vessel_snapshot_response(
|
||||
safe_limit = _safe_vessel_limit(limit)
|
||||
safe_since_minutes = min(max(int(since_minutes or 60), 1), 1440)
|
||||
observed_since = datetime.now(UTC) - timedelta(minutes=safe_since_minutes)
|
||||
snapshot_started_at = datetime.now(UTC)
|
||||
features, diagnostics = await _load_raw_vessel_snapshot_features(
|
||||
db,
|
||||
bbox=bbox,
|
||||
@@ -1137,6 +1138,7 @@ async def build_vessel_snapshot_response(
|
||||
"features": features,
|
||||
"count": len(features),
|
||||
"stats": _build_vessel_stats(features),
|
||||
"generated_at": to_iso8601_utc(snapshot_started_at),
|
||||
"diagnostics": {
|
||||
**diagnostics,
|
||||
"filtered_count": len(features),
|
||||
|
||||
@@ -161,7 +161,7 @@ async def websocket_endpoint(
|
||||
if is_anonymous:
|
||||
channels = [channel for channel in channels if channel in supported_channels]
|
||||
vessel_subscription = None
|
||||
if "vessels" in channels and "bbox" in payload_data:
|
||||
if "vessels" in channels:
|
||||
try:
|
||||
vessel_subscription = manager.subscribe_vessels(websocket, payload_data)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -138,6 +138,7 @@ class DatasourceRunStatus(StrEnum):
|
||||
class ProviderApi(StrEnum):
|
||||
ANTHROPIC_MESSAGES = "anthropic-messages"
|
||||
OPENAI_COMPLETIONS = "openai-completions"
|
||||
OPENAI_RESPONSES = "openai-responses"
|
||||
OLLAMA_GENERATE = "ollama-generate"
|
||||
|
||||
|
||||
|
||||
@@ -4,11 +4,14 @@ import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.core.websocket.manager import manager
|
||||
|
||||
|
||||
EARTH_UPDATES_CHANNEL = "earth_updates"
|
||||
VESSEL_STATE_QUERY_BATCH_SIZE = 1000
|
||||
logger = get_logger(__name__, service="websocket")
|
||||
|
||||
|
||||
class DataBroadcaster:
|
||||
@@ -114,16 +117,22 @@ class DataBroadcaster:
|
||||
return
|
||||
pending = self._pending_vessel_updates
|
||||
self._pending_vessel_updates = {}
|
||||
vessels = []
|
||||
for item in pending.values():
|
||||
vessel = dict(item)
|
||||
source = vessel.pop("_source", None)
|
||||
action = vessel.pop("_action", "upsert")
|
||||
created = vessel.pop("_created", None)
|
||||
vessel["source"] = source
|
||||
vessel["action"] = action
|
||||
vessel["created"] = created
|
||||
vessels.append(vessel)
|
||||
try:
|
||||
vessels = await self._load_current_vessel_updates(list(pending))
|
||||
except Exception:
|
||||
# Preserve newer updates that arrived during the failed database read.
|
||||
self._pending_vessel_updates = {**pending, **self._pending_vessel_updates}
|
||||
raise
|
||||
if not vessels:
|
||||
return
|
||||
vessels = [
|
||||
{
|
||||
**vessel,
|
||||
"action": vessel.get("action", "upsert"),
|
||||
"created": pending.get(str(vessel["mmsi"]), {}).get("_created"),
|
||||
}
|
||||
for vessel in vessels
|
||||
]
|
||||
await manager.broadcast_vessels(
|
||||
{
|
||||
"action": "upsert",
|
||||
@@ -133,12 +142,31 @@ class DataBroadcaster:
|
||||
}
|
||||
)
|
||||
|
||||
async def _load_current_vessel_updates(self, keys: list[str]) -> list[Dict[str, Any]]:
|
||||
from app.db.session import async_session_factory
|
||||
from app.services.vessel_ais_aggregation import get_current_vessels_by_mmsi
|
||||
|
||||
mmsis = [int(key) for key in keys if key.isdigit()]
|
||||
vessels = []
|
||||
async with async_session_factory() as db:
|
||||
for offset in range(0, len(mmsis), VESSEL_STATE_QUERY_BATCH_SIZE):
|
||||
vessels.extend(await get_current_vessels_by_mmsi(
|
||||
db, mmsis[offset:offset + VESSEL_STATE_QUERY_BATCH_SIZE]
|
||||
))
|
||||
present = {int(vessel["mmsi"]) for vessel in vessels}
|
||||
vessels.extend({"mmsi": mmsi, "action": "remove"} for mmsi in mmsis if mmsi not in present)
|
||||
return vessels
|
||||
|
||||
async def broadcast_vessels_periodically(self):
|
||||
while self.running:
|
||||
try:
|
||||
await self.flush_vessel_updates()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.exception_event(
|
||||
"Failed to flush vessel updates",
|
||||
event="vessels.broadcast.failed",
|
||||
context={"error": str(exc)},
|
||||
)
|
||||
await asyncio.sleep(self._vessel_flush_interval)
|
||||
|
||||
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):
|
||||
|
||||
@@ -63,6 +63,8 @@ class ConnectionManager:
|
||||
|
||||
def unsubscribe(self, websocket: WebSocket, channels: list[str]):
|
||||
for channel in {str(channel).strip() for channel in channels if str(channel).strip()}:
|
||||
if channel == "vessels":
|
||||
self.vessel_subscriptions.pop(websocket, None)
|
||||
subscribers = self.channel_subscriptions.get(channel)
|
||||
if subscribers is not None:
|
||||
subscribers.discard(websocket)
|
||||
@@ -88,7 +90,8 @@ class ConnectionManager:
|
||||
return subscription
|
||||
|
||||
def _normalize_vessel_subscription(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
bbox = config.get("bbox")
|
||||
global_scope = config.get("scope") == "global"
|
||||
bbox = [-180, -90, 180, 90] if global_scope else config.get("bbox")
|
||||
if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
|
||||
raise ValueError("vessels subscription requires bbox=[lon_min,lat_min,lon_max,lat_max]")
|
||||
try:
|
||||
@@ -103,7 +106,7 @@ class ConnectionManager:
|
||||
raise ValueError("bbox longitude values must be between -180 and 180")
|
||||
if not (-90 <= lat_min <= 90 and -90 <= lat_max <= 90):
|
||||
raise ValueError("bbox latitude values must be between -90 and 90")
|
||||
if (lon_max - lon_min) * (lat_max - lat_min) > MAX_VESSEL_BBOX_AREA:
|
||||
if not global_scope and (lon_max - lon_min) * (lat_max - lat_min) > MAX_VESSEL_BBOX_AREA:
|
||||
raise ValueError("bbox is too large; zoom in or request a smaller viewport")
|
||||
|
||||
zoom = int(config.get("zoom") or 1)
|
||||
@@ -116,10 +119,11 @@ class ConnectionManager:
|
||||
if str(item).strip()
|
||||
}
|
||||
return {
|
||||
"scope": "global" if global_scope else "viewport",
|
||||
"bbox": (lon_min, lat_min, lon_max, lat_max),
|
||||
"zoom": zoom,
|
||||
"limit": limit,
|
||||
"type": vessel_types,
|
||||
"type": sorted(vessel_types),
|
||||
"last_sent_at": None,
|
||||
}
|
||||
|
||||
@@ -152,7 +156,9 @@ class ConnectionManager:
|
||||
vessel
|
||||
for vessel in vessels
|
||||
if self._vessel_matches_subscription(vessel, subscription)
|
||||
][: min(subscription["limit"], MAX_VESSEL_WS_MESSAGE_ITEMS)]
|
||||
]
|
||||
if subscription.get("scope") != "global":
|
||||
matched = matched[:subscription["limit"]]
|
||||
if not matched:
|
||||
continue
|
||||
subscription["last_sent_at"] = datetime.now(UTC)
|
||||
@@ -162,16 +168,24 @@ class ConnectionManager:
|
||||
"timestamp": subscription["last_sent_at"].isoformat(),
|
||||
"payload": {
|
||||
**data,
|
||||
"vessels": matched,
|
||||
"vessels": [],
|
||||
"subscription": {
|
||||
"bbox": list(subscription["bbox"]),
|
||||
"zoom": subscription["zoom"],
|
||||
"limit": subscription["limit"],
|
||||
"scope": subscription.get("scope", "viewport"),
|
||||
},
|
||||
},
|
||||
}
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
for offset in range(0, len(matched), MAX_VESSEL_WS_MESSAGE_ITEMS):
|
||||
await connection.send_json({
|
||||
**message,
|
||||
"payload": {
|
||||
**message["payload"],
|
||||
"vessels": matched[offset:offset + MAX_VESSEL_WS_MESSAGE_ITEMS],
|
||||
},
|
||||
})
|
||||
except Exception:
|
||||
self.unsubscribe_all(connection)
|
||||
|
||||
@@ -180,6 +194,8 @@ class ConnectionManager:
|
||||
vessel: dict[str, Any],
|
||||
subscription: dict[str, Any],
|
||||
) -> bool:
|
||||
if vessel.get("action") == "remove" and subscription.get("scope") == "global":
|
||||
return True
|
||||
try:
|
||||
lon = float(vessel.get("lon"))
|
||||
lat = float(vessel.get("lat"))
|
||||
|
||||
@@ -35,7 +35,7 @@ class NewsLiveStreamsCollector(BaseCollector):
|
||||
DEFAULT_IPTV_ORG_LOGOS_URL = "https://iptv-org.github.io/api/logos.json"
|
||||
DEFAULT_IPTV_ORG_NEWS_CATEGORIES = ("news", "business", "weather")
|
||||
DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES = ("music", "sports", "kids", "entertainment")
|
||||
DEFAULT_IPTV_ORG_MAX_SOURCES = 120
|
||||
DEFAULT_IPTV_ORG_MAX_SOURCES = 0 # Zero keeps the complete matching channel catalog.
|
||||
|
||||
async def fetch(self) -> list[dict[str, Any]]:
|
||||
request_url = (self._resolved_url or "").strip()
|
||||
@@ -445,7 +445,7 @@ class NewsLiveStreamsCollector(BaseCollector):
|
||||
"reference_date": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
)
|
||||
if len(normalized) >= max_sources:
|
||||
if max_sources > 0 and len(normalized) >= max_sources:
|
||||
break
|
||||
|
||||
return normalized
|
||||
|
||||
@@ -56,6 +56,8 @@ def build_earth_update_from_db_payload(payload: dict[str, Any]) -> dict[str, Any
|
||||
source_has_adapter = bool(get_earth_update_layers_for_source(source))
|
||||
effective_source = source if source_has_adapter else (table_name if table_name else source)
|
||||
operation = payload.get("operation")
|
||||
if "vessels" in layers and operation in {"DELETE", "TRUNCATE"}:
|
||||
refresh_strategy = "reload"
|
||||
update: dict[str, Any] = {
|
||||
"event": "earth.layer.changed",
|
||||
"action": "database_changed",
|
||||
@@ -113,6 +115,8 @@ class PendingEarthDbChange:
|
||||
operation = payload.get("operation")
|
||||
if operation:
|
||||
self.operations.add(str(operation))
|
||||
if "vessels" in self.layers and operation in {"DELETE", "TRUNCATE"}:
|
||||
self.refresh_strategy = "reload"
|
||||
entity_keys = payload.get("entity_keys")
|
||||
if not isinstance(entity_keys, list):
|
||||
entity_key = payload.get("entity_key")
|
||||
@@ -176,6 +180,17 @@ class EarthDbChangeDispatcher:
|
||||
if not update:
|
||||
return False
|
||||
|
||||
if payload.get("table") == "vessel_current_state" and payload.get("operation") == "DELETE":
|
||||
from app.core.websocket.broadcaster import broadcaster
|
||||
|
||||
keys = payload.get("entity_keys")
|
||||
if not isinstance(keys, list):
|
||||
keys = [payload.get("entity_key")]
|
||||
broadcaster.enqueue_vessel_update({
|
||||
"action": "remove",
|
||||
"vessels": [{"mmsi": key} for key in keys if key is not None],
|
||||
})
|
||||
|
||||
source = update["source"]
|
||||
pending = self._pending.get(source)
|
||||
if pending is None:
|
||||
|
||||
@@ -25,6 +25,7 @@ EARTH_LAYER_ADAPTERS: tuple[EarthLayerAdapter, ...] = (
|
||||
layers=("vessels",),
|
||||
cache_patterns=("vessels*", "summary*"),
|
||||
derived_models=("ais_raw_observations", "ais_conflict_records", "ais_source_health"),
|
||||
refresh_strategy="delta",
|
||||
),
|
||||
EarthLayerAdapter(
|
||||
sources=frozenset(
|
||||
|
||||
186
backend/app/services/llm_model_catalog.py
Normal file
186
backend/app/services/llm_model_catalog.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Authenticated model discovery shared by refresh and connection checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
|
||||
CATALOG_TIMEOUT_SECONDS = 30
|
||||
CATALOG_MAX_PAGES = 100
|
||||
CATALOG_PAGE_SIZE = 100
|
||||
CATALOG_REQUEST_ATTEMPTS = 2
|
||||
CATALOG_RETRY_DELAY_SECONDS = 0.2
|
||||
SUPPORTED_PROVIDER_APIS = {
|
||||
"openai-completions",
|
||||
"openai-responses",
|
||||
"anthropic-messages",
|
||||
"ollama-generate",
|
||||
}
|
||||
|
||||
|
||||
class LLMProviderCatalogError(RuntimeError):
|
||||
"""A safe, user-facing catalog failure with no upstream response or credentials."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelCatalog:
|
||||
url: str
|
||||
models: list[str]
|
||||
|
||||
|
||||
def model_catalog_url(provider: str, base_url: str, provider_api: str) -> str:
|
||||
parts = urlsplit(base_url.strip())
|
||||
if parts.scheme not in {"http", "https"} or not parts.hostname:
|
||||
raise LLMProviderCatalogError("请填写有效的 HTTP(S) 模型基础地址。")
|
||||
if parts.username or parts.password or parts.query or parts.fragment:
|
||||
raise LLMProviderCatalogError("模型基础地址不能包含账号、密码、查询参数或片段。")
|
||||
if provider_api not in SUPPORTED_PROVIDER_APIS:
|
||||
raise LLMProviderCatalogError("当前接口协议不支持模型目录查询。")
|
||||
path = parts.path.rstrip("/")
|
||||
if provider_api == "ollama-generate":
|
||||
path = path.removesuffix("/api").removesuffix("/v1") + "/api/tags"
|
||||
elif provider == "alibaba" and parts.hostname.endswith(".aliyuncs.com"):
|
||||
path = "/api/v1/models"
|
||||
else:
|
||||
if not path or (provider_api == "anthropic-messages" and path.endswith("/anthropic")):
|
||||
path += "/v1"
|
||||
path += "/models"
|
||||
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
|
||||
|
||||
|
||||
def catalog_error_message(exc: Exception) -> str:
|
||||
if isinstance(exc, LLMProviderCatalogError):
|
||||
return str(exc)
|
||||
if isinstance(exc, (httpx.TimeoutException, TimeoutError)):
|
||||
return "模型目录请求超时,请检查网络后重试。"
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
code = exc.response.status_code
|
||||
messages = {
|
||||
401: "API Key 验证失败,请检查当前供应商的凭证。",
|
||||
403: "当前 API Key 无权访问该模型目录,请检查账号权限和服务地域。",
|
||||
404: "模型目录接口不存在,请检查基础地址、地域和接口协议。",
|
||||
429: "供应商请求限流,请稍后重试。",
|
||||
}
|
||||
return messages.get(code, f"供应商模型目录返回 HTTP {code},请稍后重试。")
|
||||
if isinstance(exc, httpx.RequestError):
|
||||
return "无法连接模型目录,请检查基础地址和网络。"
|
||||
return "模型目录响应无效,请稍后重试。"
|
||||
|
||||
|
||||
def _model_rows(payload: object) -> tuple[list[dict[str, object]], dict[str, object]]:
|
||||
if not isinstance(payload, dict):
|
||||
raise LLMProviderCatalogError("供应商返回了无效的模型目录。")
|
||||
envelope = payload.get("output", payload)
|
||||
if not isinstance(envelope, dict):
|
||||
raise LLMProviderCatalogError("供应商返回了无效的模型目录。")
|
||||
rows = envelope.get("data", envelope.get("models"))
|
||||
if not isinstance(rows, list):
|
||||
raise LLMProviderCatalogError("供应商响应中没有模型列表。")
|
||||
if any(not isinstance(row, dict) for row in rows):
|
||||
raise LLMProviderCatalogError("供应商返回了无效的模型条目。")
|
||||
return rows, envelope
|
||||
|
||||
|
||||
def _model_id(row: dict[str, object]) -> str:
|
||||
value = row.get("id") or row.get("model") or row.get("name")
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise LLMProviderCatalogError("供应商返回了缺少 ID 的模型条目。")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _model_date(row: dict[str, object]) -> float:
|
||||
value = row.get("created_at") or row.get("published_time") or row.get("created")
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
||||
except ValueError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
async def _get_catalog_page(
|
||||
client: httpx.AsyncClient,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
params: dict[str, str | int],
|
||||
) -> object:
|
||||
for attempt in range(CATALOG_REQUEST_ATTEMPTS):
|
||||
try:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if attempt or exc.response.status_code not in {502, 503, 504}:
|
||||
raise
|
||||
except httpx.TransportError:
|
||||
if attempt:
|
||||
raise
|
||||
await asyncio.sleep(CATALOG_RETRY_DELAY_SECONDS)
|
||||
raise LLMProviderCatalogError("模型目录请求失败。")
|
||||
|
||||
|
||||
async def fetch_model_catalog(
|
||||
provider: str,
|
||||
base_url: str,
|
||||
provider_api: str,
|
||||
api_key: str = "",
|
||||
anthropic_version: str = "2023-06-01",
|
||||
timeout_seconds: int = CATALOG_TIMEOUT_SECONDS,
|
||||
) -> ModelCatalog:
|
||||
url = model_catalog_url(provider, base_url, provider_api)
|
||||
public_catalog = provider in {"opencode-go", "openrouter"}
|
||||
if not api_key and provider_api != "ollama-generate" and not public_catalog:
|
||||
raise LLMProviderCatalogError("请先配置当前供应商的 API Key,再刷新模型列表。")
|
||||
headers = {"User-Agent": "Planet/1.0", "Accept": "application/json"}
|
||||
if provider_api == "anthropic-messages" and provider not in {
|
||||
"opencode-go",
|
||||
"openrouter",
|
||||
"alibaba",
|
||||
"moonshotai",
|
||||
}:
|
||||
headers.update({"x-api-key": api_key, "anthropic-version": anthropic_version})
|
||||
elif api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
native_dashscope = provider == "alibaba" and urlsplit(url).hostname.endswith(".aliyuncs.com")
|
||||
params: dict[str, str | int] = {}
|
||||
if native_dashscope:
|
||||
params = {"page_no": 1, "page_size": CATALOG_PAGE_SIZE, "capabilities": "TG"}
|
||||
rows_by_id: dict[str, dict[str, object]] = {}
|
||||
timeout = max(1, min(timeout_seconds, CATALOG_TIMEOUT_SECONDS))
|
||||
# Bound the complete pagination/retry cycle, not just each individual request.
|
||||
async with asyncio.timeout(timeout), httpx.AsyncClient(timeout=timeout) as client:
|
||||
for page in range(CATALOG_MAX_PAGES):
|
||||
payload = await _get_catalog_page(client, url, headers, params)
|
||||
rows, envelope = _model_rows(payload)
|
||||
previous_count = len(rows_by_id)
|
||||
for row in rows:
|
||||
rows_by_id[_model_id(row)] = row
|
||||
has_more = envelope.get("has_more") is True
|
||||
if native_dashscope:
|
||||
total = envelope.get("total")
|
||||
if not isinstance(total, int) or total < 0:
|
||||
raise LLMProviderCatalogError("供应商返回了无效的模型目录分页信息。")
|
||||
has_more = len(rows_by_id) < total
|
||||
params["page_no"] = page + 2
|
||||
elif has_more:
|
||||
cursor = envelope.get("last_id")
|
||||
if not isinstance(cursor, str) or not cursor or cursor == params.get("after_id"):
|
||||
raise LLMProviderCatalogError("供应商返回了无效的模型目录分页信息。")
|
||||
params["after_id"] = cursor
|
||||
if not has_more:
|
||||
models = sorted(
|
||||
rows_by_id, key=lambda key: _model_date(rows_by_id[key]), reverse=True
|
||||
)
|
||||
# An empty Ollama catalog is valid: no models have been installed yet.
|
||||
if not models and provider_api != "ollama-generate":
|
||||
raise LLMProviderCatalogError("供应商返回了空模型目录,已保留上次模型列表。")
|
||||
return ModelCatalog(url=url, models=models)
|
||||
if len(rows_by_id) == previous_count:
|
||||
raise LLMProviderCatalogError("供应商模型目录分页没有进展,已保留上次模型列表。")
|
||||
raise LLMProviderCatalogError("供应商模型目录分页超过限制,已保留上次模型列表。")
|
||||
@@ -3,17 +3,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
MODELS_DEV_URL = "https://models.dev/api.json"
|
||||
OPENCODE_GO_MODELS_URL = "https://opencode.ai/zen/go/v1/models"
|
||||
from app.services.llm_model_catalog import fetch_model_catalog
|
||||
|
||||
OPENCODE_GO_MODEL_PROVIDER_APIS = {
|
||||
"minimax-m3": "anthropic-messages",
|
||||
"qwen3.8-max": "anthropic-messages",
|
||||
"qwen3.8-flash": "anthropic-messages",
|
||||
"qwen3.7-max": "anthropic-messages",
|
||||
"qwen3.7-plus": "anthropic-messages",
|
||||
"qwen3.6-plus": "anthropic-messages",
|
||||
"grok-4.6": "openai-responses",
|
||||
"gpt-5.6-luna": "openai-responses",
|
||||
"muse-spark-1.3-contributor": "openai-responses",
|
||||
"muse-spark-1.2-contributor": "openai-responses",
|
||||
"minimax-m2.7": "anthropic-messages",
|
||||
"minimax-m2.5": "anthropic-messages",
|
||||
}
|
||||
OPENCODE_GO_FALLBACK_MODELS = [
|
||||
"minimax-m3",
|
||||
"kimi-k3",
|
||||
"glm-5.3",
|
||||
"qwen3.8-max",
|
||||
"gpt-5.6-luna",
|
||||
"minimax-m2.7",
|
||||
"minimax-m2.5",
|
||||
"kimi-k2.6",
|
||||
@@ -29,24 +42,47 @@ OPENCODE_GO_FALLBACK_MODELS = [
|
||||
]
|
||||
|
||||
|
||||
OPENAI_MODEL_PROVIDER_APIS = {
|
||||
model: "openai-responses"
|
||||
for model in [
|
||||
"gpt-6-astra",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.6-terra",
|
||||
"gpt-5.6-luna",
|
||||
"gpt-5.1",
|
||||
"gpt-5.1-codex",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
FALLBACK_LLM_PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"minimax": {
|
||||
"provider": "minimax",
|
||||
"label": "MiniMax",
|
||||
"provider_api": "anthropic-messages",
|
||||
"base_url": "https://api.minimaxi.com/anthropic",
|
||||
"model": "MiniMax-M2.7",
|
||||
"models": ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2"],
|
||||
"model": "MiniMax-M3",
|
||||
"models": [
|
||||
"MiniMax-M3",
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
"MiniMax-M2.5",
|
||||
"MiniMax-M2.5-highspeed",
|
||||
"MiniMax-M2.1",
|
||||
"MiniMax-M2.1-highspeed",
|
||||
"MiniMax-M2",
|
||||
],
|
||||
"api_key_env": "MINIMAX_API_KEY",
|
||||
"source": "fallback",
|
||||
},
|
||||
"openai": {
|
||||
"provider": "openai",
|
||||
"label": "OpenAI",
|
||||
"provider_api": "openai-completions",
|
||||
"provider_api": "openai-responses",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model": "gpt-5.1",
|
||||
"models": ["gpt-5.1", "gpt-5.1-codex", "gpt-4.1", "gpt-4o"],
|
||||
"model": "gpt-6-astra",
|
||||
"models": ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-4.1"],
|
||||
"model_provider_apis": OPENAI_MODEL_PROVIDER_APIS,
|
||||
"api_key_env": "OPENAI_API_KEY",
|
||||
"source": "fallback",
|
||||
},
|
||||
@@ -55,8 +91,8 @@ FALLBACK_LLM_PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"label": "Anthropic",
|
||||
"provider_api": "anthropic-messages",
|
||||
"base_url": "https://api.anthropic.com/v1",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"models": ["claude-sonnet-4-6", "claude-opus-4-5", "claude-3-5-haiku-20241022"],
|
||||
"model": "claude-opus-5",
|
||||
"models": ["claude-opus-5", "claude-sonnet-4-6"],
|
||||
"api_key_env": "ANTHROPIC_API_KEY",
|
||||
"source": "fallback",
|
||||
},
|
||||
@@ -65,8 +101,8 @@ FALLBACK_LLM_PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"label": "DeepSeek",
|
||||
"provider_api": "openai-completions",
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"model": "deepseek-chat",
|
||||
"models": ["deepseek-chat", "deepseek-reasoner"],
|
||||
"model": "deepseek-flash",
|
||||
"models": ["deepseek-flash", "deepseek-v4-pro"],
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
"source": "fallback",
|
||||
},
|
||||
@@ -75,8 +111,8 @@ FALLBACK_LLM_PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"label": "Alibaba Qwen / DashScope",
|
||||
"provider_api": "openai-completions",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"model": "qwen3-max",
|
||||
"models": ["qwen3-max", "qwen3.5-plus", "qwen-max", "qwen-plus"],
|
||||
"model": "qwen3.8-max",
|
||||
"models": ["qwen3.8-max", "qwen3-max", "qwen-plus"],
|
||||
"api_key_env": "DASHSCOPE_API_KEY",
|
||||
"source": "fallback",
|
||||
},
|
||||
@@ -85,8 +121,8 @@ FALLBACK_LLM_PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"label": "Moonshot AI / Kimi",
|
||||
"provider_api": "openai-completions",
|
||||
"base_url": "https://api.moonshot.ai/v1",
|
||||
"model": "kimi-k2.5",
|
||||
"models": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
|
||||
"model": "kimi-k3",
|
||||
"models": ["kimi-k3", "kimi-k2.5"],
|
||||
"api_key_env": "MOONSHOT_API_KEY",
|
||||
"source": "fallback",
|
||||
},
|
||||
@@ -123,16 +159,6 @@ FALLBACK_LLM_PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
}
|
||||
|
||||
MODELS_DEV_PROVIDER_KEYS = {
|
||||
"minimax": "minimax",
|
||||
"openai": "openai",
|
||||
"anthropic": "anthropic",
|
||||
"deepseek": "deepseek",
|
||||
"alibaba": "alibaba",
|
||||
"moonshotai": "moonshotai",
|
||||
"openrouter": "openrouter",
|
||||
}
|
||||
|
||||
|
||||
def list_fallback_llm_provider_presets() -> list[dict[str, Any]]:
|
||||
return [dict(value) for value in FALLBACK_LLM_PROVIDER_PRESETS.values()]
|
||||
@@ -152,65 +178,40 @@ def _opencode_go_model_provider_apis(model_ids: list[str]) -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
async def refresh_llm_provider_preset(provider: str, api_key: str | None = None) -> dict[str, Any]:
|
||||
async def refresh_llm_provider_preset(
|
||||
provider: str,
|
||||
api_key: str | None = None,
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
provider_api: str | None = None,
|
||||
anthropic_version: str = "2023-06-01",
|
||||
) -> dict[str, Any]:
|
||||
fallback = get_fallback_llm_provider_preset(provider)
|
||||
if fallback["provider"] == "opencode-go":
|
||||
headers = {"User-Agent": "Planet/1.0"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
||||
response = await client.get(
|
||||
OPENCODE_GO_MODELS_URL,
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
|
||||
data = payload.get("data") if isinstance(payload, dict) else []
|
||||
model_ids = [
|
||||
str(item.get("id"))
|
||||
for item in data
|
||||
if isinstance(item, dict) and item.get("id")
|
||||
][:120]
|
||||
if not model_ids:
|
||||
model_ids = fallback["models"]
|
||||
return {
|
||||
**fallback,
|
||||
"model": fallback["model"] if fallback["model"] in model_ids else model_ids[0],
|
||||
"models": model_ids,
|
||||
"model_provider_apis": _opencode_go_model_provider_apis(model_ids),
|
||||
"source": OPENCODE_GO_MODELS_URL,
|
||||
}
|
||||
|
||||
models_dev_key = MODELS_DEV_PROVIDER_KEYS.get(fallback["provider"])
|
||||
if not models_dev_key:
|
||||
return fallback
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
||||
response = await client.get(
|
||||
MODELS_DEV_URL,
|
||||
headers={"User-Agent": "Planet/1.0"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
catalog = response.json()
|
||||
|
||||
upstream = catalog.get(models_dev_key)
|
||||
if not isinstance(upstream, dict):
|
||||
return fallback
|
||||
|
||||
upstream_models = upstream.get("models") if isinstance(upstream.get("models"), dict) else {}
|
||||
model_ids = list(upstream_models.keys())[:80]
|
||||
base_url = upstream.get("api") or fallback["base_url"]
|
||||
if fallback["provider"] == "deepseek" and base_url == "https://api.deepseek.com":
|
||||
base_url = "https://api.deepseek.com/v1"
|
||||
|
||||
refreshed = {
|
||||
resolved_base_url = base_url or fallback["base_url"]
|
||||
resolved_api = provider_api or fallback["provider_api"]
|
||||
catalog = await fetch_model_catalog(
|
||||
fallback["provider"],
|
||||
resolved_base_url,
|
||||
resolved_api,
|
||||
api_key or "",
|
||||
anthropic_version,
|
||||
)
|
||||
model_provider_apis = (
|
||||
_opencode_go_model_provider_apis(catalog.models)
|
||||
if fallback["provider"] == "opencode-go"
|
||||
else fallback.get("model_provider_apis", {})
|
||||
)
|
||||
if (
|
||||
fallback["provider"] == "openai"
|
||||
and urlsplit(resolved_base_url).hostname != "api.openai.com"
|
||||
):
|
||||
model_provider_apis = {}
|
||||
return {
|
||||
**fallback,
|
||||
"label": upstream.get("name") or fallback["label"],
|
||||
"base_url": base_url,
|
||||
"model": model_ids[0] if model_ids else fallback["model"],
|
||||
"models": model_ids or fallback["models"],
|
||||
"api_key_env": (upstream.get("env") or [fallback["api_key_env"]])[0],
|
||||
"source": MODELS_DEV_URL,
|
||||
"base_url": resolved_base_url,
|
||||
"provider_api": resolved_api,
|
||||
"model": catalog.models[0] if catalog.models else "",
|
||||
"models": catalog.models,
|
||||
"model_provider_apis": model_provider_apis,
|
||||
"source": catalog.url,
|
||||
}
|
||||
return refreshed
|
||||
|
||||
@@ -635,6 +635,7 @@ async def _run_assistant_message(
|
||||
constraints=_collect_constraints(payload.constraints),
|
||||
context={
|
||||
"source": "playground",
|
||||
"session_id": session_id,
|
||||
"preset": payload.selected_preset_key,
|
||||
"conversation_history": conversation_history,
|
||||
"history_size": len(conversation_history),
|
||||
|
||||
148
backend/app/services/tv_catalog.py
Normal file
148
backend/app/services/tv_catalog.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Search and paginate the public live TV catalog at the database boundary."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.services.tv_streams import (
|
||||
TV_LIVE_SOURCE_COLLECTOR,
|
||||
TV_LIVE_SOURCE_DATA_TYPE,
|
||||
_build_collected_tv_source,
|
||||
build_public_tv_payload,
|
||||
get_tv_settings_payload,
|
||||
)
|
||||
|
||||
|
||||
def _source_key():
|
||||
return func.coalesce(
|
||||
func.nullif(CollectedData.extra_data["id"].as_string(), ""),
|
||||
func.nullif(CollectedData.source_id, ""),
|
||||
CollectedData.entity_key,
|
||||
)
|
||||
|
||||
|
||||
def _collected_catalog_query(configured_ids: list[str]) -> Select[tuple[CollectedData]]:
|
||||
metadata = CollectedData.extra_data
|
||||
source_id = _source_key()
|
||||
enabled = func.lower(func.trim(func.coalesce(metadata["is_enabled"].as_string(), "true")))
|
||||
ranked = (
|
||||
select(
|
||||
CollectedData.id,
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=source_id,
|
||||
order_by=CollectedData.id.desc(),
|
||||
)
|
||||
.label("source_rank"),
|
||||
)
|
||||
.where(
|
||||
CollectedData.source == TV_LIVE_SOURCE_COLLECTOR,
|
||||
CollectedData.data_type == TV_LIVE_SOURCE_DATA_TYPE,
|
||||
CollectedData.is_current.is_(True),
|
||||
CollectedData.is_valid == 1,
|
||||
CollectedData.deleted_at.is_(None),
|
||||
enabled.notin_(("false", "0", "no", "off")),
|
||||
source_id.notin_(configured_ids),
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
return (
|
||||
select(CollectedData)
|
||||
.join(ranked, ranked.c.id == CollectedData.id)
|
||||
.where(ranked.c.source_rank == 1)
|
||||
)
|
||||
|
||||
|
||||
def _filter_catalog_query(
|
||||
query: Select[tuple[CollectedData]], terms: list[str]
|
||||
) -> Select[tuple[CollectedData]]:
|
||||
metadata = CollectedData.extra_data
|
||||
searchable = func.lower(
|
||||
func.concat_ws(
|
||||
" ",
|
||||
CollectedData.name,
|
||||
CollectedData.title,
|
||||
CollectedData.source_id,
|
||||
metadata["name"].as_string(),
|
||||
metadata["provider"].as_string(),
|
||||
metadata["region"].as_string(),
|
||||
metadata["country"].as_string(),
|
||||
metadata["language"].as_string(),
|
||||
)
|
||||
)
|
||||
for term in terms:
|
||||
query = query.where(searchable.contains(term, autoescape=True))
|
||||
return query
|
||||
|
||||
|
||||
def _matches_source(source: dict[str, Any], terms: list[str]) -> bool:
|
||||
searchable = " ".join(
|
||||
str(source.get(key) or "") for key in ("id", "name", "provider", "region", "language")
|
||||
).lower()
|
||||
return all(term in searchable for term in terms)
|
||||
|
||||
|
||||
async def get_tv_catalog_page(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
q: str = "",
|
||||
selected_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
settings = await get_tv_settings_payload(db)
|
||||
payload = build_public_tv_payload(settings, [])
|
||||
configured = payload["sources"]
|
||||
query = _collected_catalog_query([source["id"] for source in settings["sources"]])
|
||||
if selected_id:
|
||||
selected = next((source for source in configured if source["id"] == selected_id), None)
|
||||
if selected is None:
|
||||
record = await db.scalar(query.where(_source_key() == selected_id).limit(1))
|
||||
selected = _build_collected_tv_source(record, 0) if record else None
|
||||
if selected:
|
||||
payload["selected_source"] = selected
|
||||
summary = (
|
||||
await db.execute(
|
||||
select(func.count(), func.max(CollectedData.collected_at))
|
||||
.select_from(CollectedData)
|
||||
.where(CollectedData.id.in_(query.with_only_columns(CollectedData.id)))
|
||||
)
|
||||
).one()
|
||||
total_collected, latest_update = summary
|
||||
terms = q.lower().split()
|
||||
matched_configured = [source for source in configured if _matches_source(source, terms)]
|
||||
filtered_query = _filter_catalog_query(query, terms)
|
||||
matched_collected = (
|
||||
await db.scalar(select(func.count()).select_from(filtered_query.subquery()))
|
||||
if terms
|
||||
else total_collected
|
||||
)
|
||||
sources = matched_configured[offset : offset + limit]
|
||||
remaining = limit - len(sources)
|
||||
if remaining:
|
||||
rows = await db.scalars(
|
||||
filtered_query.order_by(func.lower(CollectedData.name), CollectedData.id)
|
||||
.offset(max(0, offset - len(matched_configured)))
|
||||
.limit(remaining)
|
||||
)
|
||||
sources.extend(
|
||||
_build_collected_tv_source(record, index) for index, record in enumerate(rows)
|
||||
)
|
||||
total = len(matched_configured) + matched_collected
|
||||
next_offset = offset + len(sources)
|
||||
return {
|
||||
**payload,
|
||||
"sources": sources,
|
||||
"source_count": len(configured) + total_collected,
|
||||
"latest_updated_at": (
|
||||
to_iso8601_utc(latest_update) if latest_update else payload["latest_updated_at"]
|
||||
),
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"has_more": next_offset < total,
|
||||
"next_offset": next_offset if next_offset < total else None,
|
||||
}
|
||||
@@ -11,7 +11,7 @@ from app.core.time import to_iso8601_utc
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.models.system_setting import SystemSetting
|
||||
|
||||
DEFAULT_TV_SOURCE_ID = "cgtn-en"
|
||||
DEFAULT_TV_SOURCE_ID = "aljazeera-mubasher"
|
||||
TV_SETTINGS_CATEGORY = "tv"
|
||||
TV_LIVE_SOURCE_COLLECTOR = "news_live_streams"
|
||||
TV_LIVE_SOURCE_DATA_TYPE = "news_live_stream"
|
||||
@@ -300,8 +300,12 @@ def normalize_tv_settings(payload: dict[str, Any] | None) -> dict[str, Any]:
|
||||
]
|
||||
|
||||
if not any(source["id"] == DEFAULT_TV_SOURCE_ID for source in normalized_sources):
|
||||
default_source = next(
|
||||
source for source in DEFAULT_TV_SETTINGS["sources"]
|
||||
if source["id"] == DEFAULT_TV_SOURCE_ID
|
||||
)
|
||||
normalized_sources.append(
|
||||
normalize_tv_source(DEFAULT_TV_SETTINGS["sources"][0], index=len(normalized_sources))
|
||||
normalize_tv_source(default_source, index=len(normalized_sources))
|
||||
)
|
||||
|
||||
default_source_exists = any(
|
||||
|
||||
@@ -614,6 +614,18 @@ async def get_current_vessels_snapshot(
|
||||
return [item.to_dict() for item in result.scalars().all()]
|
||||
|
||||
|
||||
async def get_current_vessels_by_mmsi(
|
||||
db: AsyncSession, mmsis: list[int]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Read canonical render state for the vessels changed by a stream flush."""
|
||||
if not mmsis:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(VesselCurrentState).where(VesselCurrentState.mmsi.in_(mmsis))
|
||||
)
|
||||
return [_jsonable(item.to_dict()) for item in result.scalars().all()]
|
||||
|
||||
|
||||
async def aggregate_vessel_observations(
|
||||
db: AsyncSession,
|
||||
observations: Iterable[AISRawObservation],
|
||||
|
||||
@@ -63,6 +63,21 @@ def test_build_earth_update_maps_derived_tables_to_layers():
|
||||
assert vessel_update is not None
|
||||
assert vessel_update["source"] == "vessel_position"
|
||||
assert vessel_update["layers"] == ["vessels"]
|
||||
assert vessel_update["refresh_strategy"] == "reload"
|
||||
|
||||
|
||||
def test_vessel_stream_changes_do_not_request_full_layer_rebuilds():
|
||||
from app.services.earth_db_change_listener import PendingEarthDbChange
|
||||
|
||||
for source in ("aisstream_vessels", "barentswatch_vessels"):
|
||||
update = build_earth_update_from_db_payload({
|
||||
"source": source, "table": "vessel_current_state", "operation": "UPDATE",
|
||||
})
|
||||
assert update["refresh_strategy"] == "delta"
|
||||
pending = PendingEarthDbChange(source=source, layers=["vessels"], refresh_strategy="delta")
|
||||
pending.add({"operation": "UPDATE"})
|
||||
pending.add({"operation": "DELETE"})
|
||||
assert pending.refresh_strategy == "reload"
|
||||
|
||||
|
||||
def test_build_earth_update_maps_interactable_delete_to_delta():
|
||||
|
||||
219
backend/tests/test_llm_provider_catalog.py
Normal file
219
backend/tests/test_llm_provider_catalog.py
Normal file
@@ -0,0 +1,219 @@
|
||||
from copy import deepcopy
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services import llm_model_catalog as discovery
|
||||
from app.services import llm_provider_catalog as catalog
|
||||
|
||||
REAL_CLIENT = httpx.AsyncClient
|
||||
|
||||
|
||||
def mock_http(monkeypatch, handler):
|
||||
transport = httpx.MockTransport(handler)
|
||||
monkeypatch.setattr(
|
||||
discovery.httpx, "AsyncClient", lambda **kw: REAL_CLIENT(transport=transport, **kw)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"provider,path,auth",
|
||||
[
|
||||
("minimax", "/anthropic/v1/models", "x-api-key"),
|
||||
("anthropic", "/v1/models", "x-api-key"),
|
||||
("openai", "/v1/models", "authorization"),
|
||||
("deepseek", "/v1/models", "authorization"),
|
||||
("alibaba", "/api/v1/models", "authorization"),
|
||||
("moonshotai", "/v1/models", "authorization"),
|
||||
("openrouter", "/api/v1/models", "authorization"),
|
||||
("opencode-go", "/zen/go/v1/models", "authorization"),
|
||||
("ollama", "/api/tags", "authorization"),
|
||||
],
|
||||
)
|
||||
async def test_official_catalog_requests(monkeypatch, provider, path, auth):
|
||||
preset = catalog.get_fallback_llm_provider_preset(provider)
|
||||
|
||||
def handle(request):
|
||||
assert request.url.path == path
|
||||
assert request.url.host == httpx.URL(preset["base_url"]).host
|
||||
assert request.headers[auth] == ("test-key" if auth == "x-api-key" else "Bearer test-key")
|
||||
if provider == "alibaba":
|
||||
assert request.url.params["capabilities"] == "TG"
|
||||
return httpx.Response(
|
||||
200, json={"output": {"total": 1, "models": [{"model": "latest"}]}}
|
||||
)
|
||||
if provider == "ollama":
|
||||
return httpx.Response(200, json={"models": [{"name": "latest:7b"}]})
|
||||
return httpx.Response(200, json={"data": [{"id": "latest"}]})
|
||||
|
||||
mock_http(monkeypatch, handle)
|
||||
result = await catalog.refresh_llm_provider_preset(provider, api_key="test-key")
|
||||
assert result["models"] == (["latest:7b"] if provider == "ollama" else ["latest"])
|
||||
assert result["base_url"] == preset["base_url"]
|
||||
assert "test-key" not in str(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider,base,api,path",
|
||||
[
|
||||
(
|
||||
"minimax",
|
||||
"https://api.minimax.io/anthropic/v1/",
|
||||
"anthropic-messages",
|
||||
"/anthropic/v1/models",
|
||||
),
|
||||
("anthropic", "https://api.anthropic.com", "anthropic-messages", "/v1/models"),
|
||||
(
|
||||
"alibaba",
|
||||
"https://workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
|
||||
"openai-completions",
|
||||
"/api/v1/models",
|
||||
),
|
||||
(
|
||||
"alibaba",
|
||||
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
"openai-completions",
|
||||
"/api/v1/models",
|
||||
),
|
||||
("alibaba", "https://custom.test/gateway/v1", "openai-completions", "/gateway/v1/models"),
|
||||
("moonshotai", "https://api.moonshot.cn/v1", "openai-completions", "/v1/models"),
|
||||
("ollama", "http://localhost:11434/api", "ollama-generate", "/api/tags"),
|
||||
],
|
||||
)
|
||||
def test_urls_preserve_region_and_gateway(provider, base, api, path):
|
||||
url = httpx.URL(discovery.model_catalog_url(provider, base, api))
|
||||
assert url.host == httpx.URL(base).host
|
||||
assert url.path == path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_pagination_and_release_order(monkeypatch):
|
||||
seen = []
|
||||
|
||||
def handle(request):
|
||||
seen.append(request.url.params.get("after_id"))
|
||||
if len(seen) == 1:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"data": [{"id": "old", "created_at": "2025-01-01T00:00:00Z"}],
|
||||
"has_more": True,
|
||||
"last_id": "old",
|
||||
},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"data": [{"id": "new", "created_at": "2026-06-01T00:00:00Z"}], "has_more": False},
|
||||
)
|
||||
|
||||
mock_http(monkeypatch, handle)
|
||||
result = await catalog.refresh_llm_provider_preset("anthropic", "test-key")
|
||||
assert seen == [None, "old"]
|
||||
assert result["models"] == ["new", "old"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashscope_pagination(monkeypatch):
|
||||
def handle(request):
|
||||
page = int(request.url.params["page_no"])
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"output": {
|
||||
"total": 2,
|
||||
"models": [
|
||||
{"model": f"page-{page}", "published_time": f"2026-06-0{page} 00:00:00"}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
mock_http(monkeypatch, handle)
|
||||
result = await catalog.refresh_llm_provider_preset("alibaba", "test-key")
|
||||
assert result["models"] == ["page-2", "page-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{},
|
||||
{"data": []},
|
||||
{"data": [None]},
|
||||
{"data": [{}]},
|
||||
{"data": [{"id": "same"}], "has_more": True, "last_id": "same"},
|
||||
],
|
||||
)
|
||||
async def test_invalid_or_incomplete_catalog_fails(monkeypatch, payload):
|
||||
mock_http(monkeypatch, lambda request: httpx.Response(200, json=payload))
|
||||
with pytest.raises(discovery.LLMProviderCatalogError):
|
||||
await catalog.refresh_llm_provider_preset("minimax", "test-key")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_models_retained_and_defaults_unchanged(monkeypatch):
|
||||
before = deepcopy(catalog.FALLBACK_LLM_PROVIDER_PRESETS)
|
||||
rows = [{"id": f"model-{i}", "created": i} for i in range(140)]
|
||||
mock_http(monkeypatch, lambda request: httpx.Response(200, json={"data": rows}))
|
||||
result = await catalog.refresh_llm_provider_preset("openai", "test-key")
|
||||
assert len(result["models"]) == 140
|
||||
assert result["models"][0] == "model-139"
|
||||
assert catalog.FALLBACK_LLM_PROVIDER_PRESETS == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_ollama_catalog_is_valid(monkeypatch):
|
||||
mock_http(monkeypatch, lambda request: httpx.Response(200, json={"models": []}))
|
||||
result = await catalog.refresh_llm_provider_preset("ollama")
|
||||
assert result["models"] == []
|
||||
assert result["model"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_opencode_documented_protocols(monkeypatch):
|
||||
models = [
|
||||
"minimax-m3",
|
||||
"qwen3.8-max",
|
||||
"gpt-5.6-luna",
|
||||
"grok-4.6",
|
||||
"muse-spark-1.3-contributor",
|
||||
"kimi-k3",
|
||||
]
|
||||
mock_http(
|
||||
monkeypatch,
|
||||
lambda request: httpx.Response(200, json={"data": [{"id": model} for model in models]}),
|
||||
)
|
||||
result = await catalog.refresh_llm_provider_preset("opencode-go")
|
||||
assert list(result["model_provider_apis"].values()) == [
|
||||
"anthropic-messages",
|
||||
"anthropic-messages",
|
||||
"openai-responses",
|
||||
"openai-responses",
|
||||
"openai-responses",
|
||||
"openai-completions",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_transient_error_only(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def handle(request):
|
||||
calls.append(request)
|
||||
return httpx.Response(503 if len(calls) == 1 else 200, json={"data": [{"id": "latest"}]})
|
||||
|
||||
mock_http(monkeypatch, handle)
|
||||
await catalog.refresh_llm_provider_preset("minimax", "test-key")
|
||||
assert len(calls) == 2
|
||||
calls.clear()
|
||||
|
||||
def unauthorized(request):
|
||||
calls.append(request)
|
||||
return httpx.Response(401, text="secret-upstream-text")
|
||||
|
||||
mock_http(monkeypatch, unauthorized)
|
||||
with pytest.raises(httpx.HTTPStatusError) as error:
|
||||
await catalog.refresh_llm_provider_preset("minimax", "test-key")
|
||||
assert len(calls) == 1
|
||||
assert "secret-upstream-text" not in discovery.catalog_error_message(error.value)
|
||||
165
backend/tests/test_llm_provider_settings.py
Normal file
165
backend/tests/test_llm_provider_settings.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from copy import deepcopy
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.v1 import settings as api
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.services import llm_model_catalog as discovery
|
||||
from app.services.llm_provider_catalog import list_fallback_llm_provider_presets
|
||||
|
||||
REAL_CLIENT = httpx.AsyncClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings_db(monkeypatch, tmp_path):
|
||||
# Real SQL reads, commits and reloads; do not mock the settings helpers.
|
||||
engine = create_engine("sqlite://")
|
||||
SystemSetting.__table__.create(engine)
|
||||
session = Session(engine)
|
||||
db = SimpleNamespace(
|
||||
execute=AsyncMock(side_effect=session.execute),
|
||||
add=session.add,
|
||||
commit=AsyncMock(side_effect=session.commit),
|
||||
refresh=AsyncMock(side_effect=session.refresh),
|
||||
)
|
||||
monkeypatch.setattr(api, "AI_PROVIDER_ENV_FILE", tmp_path / "missing.env")
|
||||
monkeypatch.setattr(api, "_resolve_env_secret", lambda *names: ("", ""))
|
||||
yield db
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def use_upstream(monkeypatch, handler):
|
||||
transport = httpx.MockTransport(handler)
|
||||
monkeypatch.setattr(
|
||||
discovery.httpx, "AsyncClient", lambda **kw: REAL_CLIENT(transport=transport, **kw)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_database_lists_all_builtin_presets(settings_db):
|
||||
result = await api.get_ai_provider_presets(current_user=None, db=settings_db)
|
||||
assert len(result["data"]) == 9
|
||||
assert all(row["models"] for row in result["data"])
|
||||
assert "MiniMax-M3" in result["data"][0]["models"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_commits_reloads_and_preserves_runtime_and_draft(settings_db, monkeypatch):
|
||||
runtime = {
|
||||
"ai_provider": {
|
||||
"default_provider": "minimax",
|
||||
"service_token": "private-service-token",
|
||||
"providers": {
|
||||
"minimax": {
|
||||
"base_url": "https://saved.test/anthropic",
|
||||
"model": "old",
|
||||
"api_key": "saved-secret",
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
await api.save_setting_payload(settings_db, "external_integrations", deepcopy(runtime))
|
||||
before = await api.get_setting_payload(settings_db, "external_integrations")
|
||||
seen = []
|
||||
|
||||
def handle(request):
|
||||
seen.append(request)
|
||||
assert str(request.url) == "https://draft.test/anthropic/v1/models"
|
||||
assert request.headers["x-api-key"] == "draft-secret"
|
||||
return httpx.Response(200, json={"data": [{"id": "MiniMax-M3"}, {"id": "old"}]})
|
||||
|
||||
use_upstream(monkeypatch, handle)
|
||||
draft = api.AIProviderIntegrationUpdate(
|
||||
provider="minimax",
|
||||
base_url="https://draft.test/anthropic",
|
||||
model="unsaved-model",
|
||||
api_key="draft-secret",
|
||||
service_token="unsaved-service-token",
|
||||
)
|
||||
refreshed = await api.refresh_ai_provider_preset("minimax", None, settings_db, draft)
|
||||
result = await api.get_ai_provider_presets(None, settings_db)
|
||||
preset = next(row for row in result["data"] if row["provider"] == "minimax")
|
||||
assert len(seen) == 1
|
||||
assert preset["models"] == ["MiniMax-M3", "old"]
|
||||
assert preset["refreshed_at"] == refreshed["data"]["refreshed_at"]
|
||||
assert await api.get_setting_payload(settings_db, "external_integrations") == before
|
||||
assert "secret" not in str(result)
|
||||
assert "private-service-token" not in str(result)
|
||||
assert draft.model == "unsaved-model"
|
||||
stored = await settings_db.execute(
|
||||
select(SystemSetting).where(SystemSetting.category == "llm_provider_preset:minimax")
|
||||
)
|
||||
assert stored.scalar_one().payload["models"] == ["MiniMax-M3", "old"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_refresh_preserves_stored_catalog_and_sanitizes_errors(
|
||||
settings_db, monkeypatch
|
||||
):
|
||||
await api.save_setting_payload(
|
||||
settings_db, "llm_provider_preset:openrouter", {"models": ["saved-model"]}
|
||||
)
|
||||
use_upstream(monkeypatch, lambda request: httpx.Response(401, text="private-upstream-secret"))
|
||||
commits = settings_db.commit.await_count
|
||||
with pytest.raises(api.HTTPException) as error:
|
||||
await api.refresh_ai_provider_preset("openrouter", None, settings_db)
|
||||
assert error.value.status_code == 502
|
||||
assert "private-upstream-secret" not in error.value.detail
|
||||
assert settings_db.commit.await_count == commits
|
||||
assert (await api.get_setting_payload(settings_db, "llm_provider_preset:openrouter"))[
|
||||
"models"
|
||||
] == ["saved-model"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_key_is_actionable_and_does_not_request_upstream(settings_db, monkeypatch):
|
||||
def fail_request(request):
|
||||
pytest.fail("must not query a private catalog without credentials")
|
||||
|
||||
use_upstream(monkeypatch, fail_request)
|
||||
with pytest.raises(api.HTTPException) as error:
|
||||
await api.refresh_ai_provider_preset("minimax", None, settings_db)
|
||||
assert error.value.status_code == 400
|
||||
assert "API Key" in error.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"preset", list_fallback_llm_provider_presets(), ids=lambda p: p["provider"]
|
||||
)
|
||||
async def test_404_never_passes_by_builtin_model_name(monkeypatch, preset):
|
||||
use_upstream(monkeypatch, lambda request: httpx.Response(404, text="private-upstream-secret"))
|
||||
result = await api._check_ai_provider_lightweight(
|
||||
{**preset, "api_key": "invalid-key", "preset_models": preset["models"]}, 5
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert result["connected"] is False
|
||||
assert "private-upstream-secret" not in result["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("payload", [{}, {"data": []}, {"data": [{"id": "different-model"}]}])
|
||||
async def test_invalid_or_missing_models_never_pass(monkeypatch, payload):
|
||||
use_upstream(monkeypatch, lambda request: httpx.Response(200, json=payload))
|
||||
preset = list_fallback_llm_provider_presets()[0]
|
||||
result = await api._check_ai_provider_lightweight({**preset, "api_key": "test-key"}, 5)
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_model_does_not_need_a_builtin_whitelist(monkeypatch):
|
||||
use_upstream(
|
||||
monkeypatch, lambda request: httpx.Response(200, json={"data": [{"id": "future-model"}]})
|
||||
)
|
||||
preset = list_fallback_llm_provider_presets()[0]
|
||||
result = await api._check_ai_provider_lightweight(
|
||||
{**preset, "model": "future-model", "api_key": "test-key"}, 5
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["url"].endswith("/anthropic/v1/models")
|
||||
141
backend/tests/test_provider_protocols.py
Normal file
141
backend/tests/test_provider_protocols.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
with patch(
|
||||
"pydantic_settings.sources.providers.dotenv.DotEnvSettingsSource._read_env_files",
|
||||
return_value={},
|
||||
):
|
||||
from aiprovider.provider_service import ProviderService
|
||||
from aiprovider.schemas import SituationalAnalysisRequest
|
||||
from app.api.v1.settings import _runtime_config_from_ai_payload
|
||||
|
||||
|
||||
def test_custom_openai_gateway_keeps_its_configured_protocol():
|
||||
config = _runtime_config_from_ai_payload(
|
||||
{
|
||||
"default_provider": "openai",
|
||||
"providers": {
|
||||
"openai": {
|
||||
"base_url": "https://gateway.test/v1",
|
||||
"provider_api": "openai-completions",
|
||||
"model": "gpt-6-astra",
|
||||
},
|
||||
},
|
||||
}
|
||||
)["llm_config"]
|
||||
service = ProviderService({**config, "api_key": "test-key"})
|
||||
assert service._resolve_model_provider_api("gpt-6-astra") == "openai-completions"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"model,path",
|
||||
[("minimax-m3", "/messages"), ("qwen3.8-max", "/messages"), ("gpt-5.6-luna", "/responses")],
|
||||
)
|
||||
async def test_runtime_model_routes_use_current_protocols(monkeypatch, model, path):
|
||||
llm = _runtime_config_from_ai_payload(
|
||||
{
|
||||
"default_provider": "opencode-go",
|
||||
"providers": {
|
||||
"opencode-go": {
|
||||
"model": model,
|
||||
"api_key": "test-key",
|
||||
"model_provider_apis": {model: "openai-completions"},
|
||||
}
|
||||
},
|
||||
}
|
||||
)["llm_config"]
|
||||
service = ProviderService(llm)
|
||||
seen = []
|
||||
|
||||
async def post(**request):
|
||||
seen.append(request)
|
||||
assert request["path"] == path
|
||||
assert request["request_body"]["model"] == model
|
||||
if path == "/responses":
|
||||
assert request["request_body"]["store"] is False
|
||||
assert "max_tokens" not in request["request_body"]
|
||||
return {
|
||||
"output": [
|
||||
{"type": "reasoning", "summary": [{"text": "reason"}]},
|
||||
{"type": "message", "content": [{"type": "output_text", "text": "OK"}]},
|
||||
]
|
||||
}
|
||||
return {"content": [{"type": "text", "text": "OK"}]}
|
||||
|
||||
monkeypatch.setattr(service, "_post", post)
|
||||
result = await service.analyze(SituationalAnalysisRequest(title="test", objective="reply OK"))
|
||||
assert len(seen) == 1
|
||||
assert result.content == "OK"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_minimax_messages_url_accepts_both_documented_base_forms(monkeypatch):
|
||||
seen = []
|
||||
client_type = httpx.AsyncClient
|
||||
|
||||
def handle(request):
|
||||
seen.append(str(request.url))
|
||||
return httpx.Response(200, json={"content": [{"type": "text", "text": "OK"}]})
|
||||
|
||||
transport = httpx.MockTransport(handle)
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: client_type(transport=transport, **kw))
|
||||
for base in ["https://api.minimaxi.com/anthropic", "https://api.minimaxi.com/anthropic/v1"]:
|
||||
service = ProviderService(
|
||||
{
|
||||
"provider": "minimax",
|
||||
"provider_api": "anthropic-messages",
|
||||
"base_url": base,
|
||||
"api_key": "test-key",
|
||||
"model": "MiniMax-M3",
|
||||
}
|
||||
)
|
||||
result = await service.analyze(
|
||||
SituationalAnalysisRequest(title="test", objective="reply OK")
|
||||
)
|
||||
assert result.content == "OK"
|
||||
assert seen == ["https://api.minimaxi.com/anthropic/v1/messages"] * 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_m3_playground_thinking_and_opencode_session_headers(monkeypatch):
|
||||
seen = []
|
||||
client_type = httpx.AsyncClient
|
||||
|
||||
def handle(request):
|
||||
import json
|
||||
|
||||
seen.append(request)
|
||||
assert json.loads(request.content)["thinking"] == {"type": "adaptive"}
|
||||
assert request.headers["user-agent"] == "Planet/1.0"
|
||||
return httpx.Response(200, json={"content": [{"type": "text", "text": "OK"}]})
|
||||
|
||||
transport = httpx.MockTransport(handle)
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: client_type(transport=transport, **kw))
|
||||
for session_id in ["conversation-one", "conversation-one", "conversation-two"]:
|
||||
service = ProviderService(
|
||||
{
|
||||
"provider": "opencode-go",
|
||||
"provider_api": "anthropic-messages",
|
||||
"base_url": "https://opencode.ai/zen/go/v1",
|
||||
"api_key": "test-key",
|
||||
"model": "minimax-m3",
|
||||
}
|
||||
)
|
||||
await service.analyze(
|
||||
SituationalAnalysisRequest(
|
||||
title="test",
|
||||
objective="reply OK",
|
||||
thinking={"type": "enabled"},
|
||||
context={"session_id": session_id},
|
||||
)
|
||||
)
|
||||
session_headers = [request.headers["x-opencode-session"] for request in seen]
|
||||
assert session_headers[0] == session_headers[1]
|
||||
assert session_headers[0] != session_headers[2]
|
||||
160
backend/tests/test_tv_catalog.py
Normal file
160
backend/tests/test_tv_catalog.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import Base
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.models.data_snapshot import DataSnapshot
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.task import CollectionTask
|
||||
from app.services.collectors.news_live_streams import NewsLiveStreamsCollector
|
||||
from app.services.tv_catalog import get_tv_catalog_page
|
||||
from app.services.tv_streams import normalize_tv_settings
|
||||
|
||||
|
||||
class CatalogSession:
|
||||
"""Execute the real catalog queries against an isolated SQLite database."""
|
||||
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
async def execute(self, query):
|
||||
return self.session.execute(query)
|
||||
|
||||
async def scalar(self, query):
|
||||
return self.session.scalar(query)
|
||||
|
||||
async def scalars(self, query):
|
||||
return self.session.scalars(query)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def catalog_db():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def register_functions(connection, _record):
|
||||
connection.create_function(
|
||||
"concat_ws",
|
||||
-1,
|
||||
lambda sep, *args: sep.join(str(arg) for arg in args if arg is not None),
|
||||
)
|
||||
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
CollectionTask.__table__,
|
||||
DataSnapshot.__table__,
|
||||
CollectedData.__table__,
|
||||
SystemSetting.__table__,
|
||||
],
|
||||
)
|
||||
with Session(engine) as session:
|
||||
for index in range(135):
|
||||
session.add(
|
||||
CollectedData(
|
||||
source="news_live_streams",
|
||||
source_id=f"channel-{index:03}",
|
||||
data_type="news_live_stream",
|
||||
name=f"Channel {index:03}",
|
||||
collected_at=datetime(2026, 9, 13, tzinfo=UTC),
|
||||
is_current=True,
|
||||
is_valid=1,
|
||||
extra_data={
|
||||
"stream_url": "https://example.invalid/live.m3u8",
|
||||
"region": "Canada",
|
||||
},
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
yield CatalogSession(session)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pages_include_entire_catalog_without_overlap(catalog_db):
|
||||
first = await get_tv_catalog_page(catalog_db, limit=50)
|
||||
second = await get_tv_catalog_page(catalog_db, offset=first["next_offset"], limit=50)
|
||||
third = await get_tv_catalog_page(catalog_db, offset=second["next_offset"], limit=50)
|
||||
ids = [source["id"] for page in (first, second, third) for source in page["sources"]]
|
||||
assert [len(page["sources"]) for page in (first, second, third)] == [50, 50, 45]
|
||||
assert len(set(ids)) == first["source_count"] == 145
|
||||
assert ids[-1] == "channel-134"
|
||||
assert third["next_offset"] is None and not third["has_more"]
|
||||
beyond = await get_tv_catalog_page(catalog_db, offset=200)
|
||||
assert beyond["sources"] == [] and not beyond["has_more"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_finds_later_pages_and_treats_wildcards_literally(catalog_db):
|
||||
payload = await get_tv_catalog_page(catalog_db, q="CANADA 134")
|
||||
assert [source["id"] for source in payload["sources"]] == ["channel-134"]
|
||||
assert payload["total"] == 1 and payload["source_count"] == 145
|
||||
assert (await get_tv_catalog_page(catalog_db, q="%"))["total"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_survives_refresh_when_outside_first_page(catalog_db):
|
||||
payload = await get_tv_catalog_page(catalog_db, selected_id="channel-134")
|
||||
assert payload["selected_source"]["id"] == "channel-134"
|
||||
assert "channel-134" not in [source["id"] for source in payload["sources"]]
|
||||
assert payload["default_source_id"] == "aljazeera-mubasher"
|
||||
default = (await get_tv_catalog_page(catalog_db, selected_id="removed"))["selected_source"]
|
||||
assert default["id"] == "aljazeera-mubasher" and default["source_type"] == "hls"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_hides_inactive_records_and_deduplicates_ids(catalog_db):
|
||||
for name, values in [
|
||||
("Disabled", {"extra_data": {"is_enabled": False}}),
|
||||
("Historical", {"is_current": False}),
|
||||
("Invalid", {"is_valid": 0}),
|
||||
("Deleted", {"deleted_at": datetime.now(UTC)}),
|
||||
("Replacement", {"source_id": "channel-134"}),
|
||||
]:
|
||||
record = dict(
|
||||
source="news_live_streams",
|
||||
source_id=name,
|
||||
name=name,
|
||||
data_type="news_live_stream",
|
||||
is_current=True,
|
||||
is_valid=1,
|
||||
)
|
||||
catalog_db.session.add(CollectedData(**{**record, **values}))
|
||||
catalog_db.session.flush()
|
||||
payload = await get_tv_catalog_page(catalog_db, q="replacement")
|
||||
assert payload["source_count"] == 145
|
||||
assert [source["id"] for source in payload["sources"]] == ["channel-134"]
|
||||
|
||||
|
||||
def test_missing_builtin_default_adds_aljazeera_without_losing_custom_source():
|
||||
settings = normalize_tv_settings({"sources": [{"id": "custom", "name": "Custom"}]})
|
||||
assert settings["default_source_id"] == "aljazeera-mubasher"
|
||||
assert {source["id"] for source in settings["sources"]} == {"custom", "aljazeera-mubasher"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"config, expected", [({}, 135), ({"max_sources": 0}, 135), ({"max_sources": 7}, 7)]
|
||||
)
|
||||
async def test_collector_keeps_all_matching_channels_unless_explicitly_limited(
|
||||
monkeypatch, config, expected
|
||||
):
|
||||
collector = NewsLiveStreamsCollector()
|
||||
channels = [
|
||||
{"id": f"channel-{i}", "name": f"Channel {i}", "categories": ["news"]} for i in range(135)
|
||||
]
|
||||
channels.append({"id": "sport", "name": "Sports", "categories": ["sports"]})
|
||||
streams = [
|
||||
{"channel": channel["id"], "url": "https://example.invalid/live.m3u8"}
|
||||
for channel in channels
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
collector, "_gather_iptv_org_payloads", AsyncMock(return_value=(channels, streams, []))
|
||||
)
|
||||
records = await collector._fetch_iptv_org("https://example.invalid/channels.json", config)
|
||||
assert len(records) == expected
|
||||
assert all(record["source_id"] != "sport" for record in records)
|
||||
@@ -1,5 +1,9 @@
|
||||
import pytest
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.websocket.manager import ConnectionManager
|
||||
from app.core.websocket.broadcaster import DataBroadcaster
|
||||
@@ -113,6 +117,11 @@ async def test_vessel_broadcaster_keeps_latest_update_per_mmsi(monkeypatch):
|
||||
broadcaster_module = importlib.import_module("app.core.websocket.broadcaster")
|
||||
monkeypatch.setattr(broadcaster_module.manager, "broadcast_vessels", fake_broadcast_vessels)
|
||||
broadcaster = DataBroadcaster()
|
||||
async def load_current(keys):
|
||||
assert keys == ["1"]
|
||||
return [{"mmsi": 1, "lat": 60.0, "lon": 11.0, "source": "barentswatch_vessels"}]
|
||||
|
||||
monkeypatch.setattr(broadcaster, "_load_current_vessel_updates", load_current)
|
||||
broadcaster.enqueue_vessel_update(
|
||||
{
|
||||
"source": "aisstream_vessels",
|
||||
@@ -129,10 +138,67 @@ async def test_vessel_broadcaster_keeps_latest_update_per_mmsi(monkeypatch):
|
||||
assert sent[0]["vessels"] == [
|
||||
{
|
||||
"mmsi": 1,
|
||||
"lat": 59.1,
|
||||
"lon": 10.1,
|
||||
"source": "aisstream_vessels",
|
||||
"lat": 60.0,
|
||||
"lon": 11.0,
|
||||
"source": "barentswatch_vessels",
|
||||
"action": "upsert",
|
||||
"created": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_vessel_subscription_delivers_every_item_in_bounded_frames():
|
||||
manager = ConnectionManager()
|
||||
socket = FakeWebSocket()
|
||||
config = manager.subscribe_vessels(socket, {"scope": "global", "zoom": 4})
|
||||
json.dumps(config)
|
||||
vessels = [{"mmsi": index, "lat": 60, "lon": 10} for index in range(2501)]
|
||||
await manager.broadcast_vessels({"vessels": vessels})
|
||||
|
||||
assert [len(frame["payload"]["vessels"]) for frame in socket.sent] == [1000, 1000, 501]
|
||||
assert [item for frame in socket.sent for item in frame["payload"]["vessels"]] == vessels
|
||||
manager.unsubscribe(socket, ["vessels"])
|
||||
await manager.broadcast_vessels({"vessels": vessels})
|
||||
assert len(socket.sent) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_vessel_removal_does_not_require_coordinates():
|
||||
manager = ConnectionManager()
|
||||
socket = FakeWebSocket()
|
||||
manager.subscribe_vessels(socket, {"scope": "global", "zoom": 4})
|
||||
await manager.broadcast_vessels({"vessels": [{"mmsi": 123, "action": "remove"}]})
|
||||
assert socket.sent[0]["payload"]["vessels"] == [{"mmsi": 123, "action": "remove"}]
|
||||
|
||||
|
||||
def test_anonymous_earth_can_confirm_global_vessel_subscription(monkeypatch):
|
||||
websocket_module = importlib.import_module("app.api.v1.websocket")
|
||||
monkeypatch.setattr(websocket_module, "manager", ConnectionManager())
|
||||
app = FastAPI()
|
||||
app.include_router(websocket_module.router)
|
||||
with TestClient(app) as client, client.websocket_connect("/ws") as socket:
|
||||
assert socket.receive_json()["type"] == "connection_established"
|
||||
socket.send_json({
|
||||
"type": "subscribe",
|
||||
"data": {"channels": ["earth_updates", "vessels"], "scope": "global", "zoom": 4},
|
||||
})
|
||||
response = socket.receive_json()
|
||||
assert response["type"] == "subscription_confirmed"
|
||||
assert response["data"]["vessels"]["scope"] == "global"
|
||||
assert response["data"]["vessels"]["type"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessel_flush_retries_without_overwriting_newer_queued_updates(monkeypatch):
|
||||
broadcaster = DataBroadcaster()
|
||||
broadcaster.enqueue_vessel_update({"vessels": [{"mmsi": 1, "lat": 59, "lon": 10}]})
|
||||
|
||||
async def fail_read(_keys):
|
||||
broadcaster.enqueue_vessel_update({"vessels": [{"mmsi": 1, "lat": 61, "lon": 12}]})
|
||||
raise RuntimeError("database unavailable")
|
||||
|
||||
monkeypatch.setattr(broadcaster, "_load_current_vessel_updates", fail_read)
|
||||
with pytest.raises(RuntimeError, match="database unavailable"):
|
||||
await broadcaster.flush_vessel_updates()
|
||||
assert broadcaster._pending_vessel_updates["1"]["lat"] == 61
|
||||
|
||||
@@ -8,6 +8,40 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.74.5] — 2026-09-13
|
||||
|
||||
Released: 2026-09-13
|
||||
|
||||
### Highlights
|
||||
- 恢复模型供应商预设的加载与持久化,模型刷新和轻量连接检查统一使用供应商官方目录接口。
|
||||
- 修复 MiniMax M3 思考模式和 OpenCode Go 模型协议路由,补齐 Responses 适配。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 修复 `llm_provider_preset:*` 动态设置分类触发 KeyError 和列表 500,使用真实数据库读写回归验证保存与重载。
|
||||
- 统一九家供应商的目录 URL、鉴权、响应解析与分页,保留服务地域和自定义地址;明确处理限流、空目录和失败,移除 404 白名单误判成功。
|
||||
- 模型刷新读取表单草稿地址和凭证,保留默认模型、未保存修改及失败前目录;连接提示区分目录可用与实际生成成功。
|
||||
- 增加 Responses 请求与响应解析、M3 adaptive 思考适配和 OpenCode 会话标识;已知模型选择同步协议,自定义 OpenAI 网关保留手动协议。
|
||||
- 同步中英文接口说明、操作手册与快速开始,并覆盖模型目录失败、草稿保留和协议适配回归。
|
||||
|
||||
---
|
||||
|
||||
## [0.74.4] — 2026-09-13
|
||||
|
||||
Released: 2026-09-13
|
||||
|
||||
### Highlights
|
||||
- 保留 Earth 全量对象与交互,优化海缆、登陆点和卫星绘制,并让 AIS / BarentsWatch 船只通过确认状态增量更新。
|
||||
- 新闻直播支持完整频道目录搜索和无限滚动;模型目录刷新可保存结果,启动流程减少不必要的依赖安装与等待。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 海缆和登陆点合批绘制,卫星 SGP4 计算移入 Worker、呼吸动画移入 GPU,并缩小动态文本的翻译扫描范围。
|
||||
- 船舶全局订阅按 MMSI 合并、拆包和原位更新,保留选择状态,并处理删除、重连及旧快照覆盖。
|
||||
- 新闻直播取消默认 120 项采集截断,增加数据库分页、完整目录搜索和固定计数栏,默认源改为半岛电视台 HLS。
|
||||
- 算力中心定位队列在当前 Earth 页面内独立于详情面板继续运行;模型刷新保存新目录并保留当前模型、凭证草稿与失败前目录。
|
||||
- AI Provider 镜像使用独立依赖组,容器直接运行已安装环境;启动提前验证数据库、及时识别后端失败,并保留可复用容器。
|
||||
|
||||
---
|
||||
|
||||
## [0.74.3] — 2026-09-13
|
||||
|
||||
Released: 2026-09-13
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## 当前状态
|
||||
|
||||
最新实现继续保留分桶 `Points` 与全局数据范围,并已接通 `vessels` 全局实时订阅:后端读取确认状态后按 MMSI 拆包推送,前端原位修改缓冲;快照用于首次加载、重连和删除校准。旧快照不能覆盖较新的更新或删除,旋转与缩放仍不触发视口请求。下方 Sprite 问题分析和阶段方案保留为历史背景,当前约束以[地球前端上下文](../technical/zh/earth-frontend-context.md)为准。
|
||||
|
||||
该计划的前端核心部分已经在 `0.44.1` 落地,但最终实现不是原文设想的 `InstancedBufferGeometry` quad,而是更稳的分桶 `THREE.Points` 方案:
|
||||
|
||||
- 普通船只按 moving / anchored 和 `VESSEL_COURSE_BINS` 航向分桶,使用 `PointsMaterial` 批量绘制。
|
||||
|
||||
@@ -51,6 +51,7 @@ That split makes MiniMax, Claude-compatible gateways, and self-hosted OpenAI-com
|
||||
Supported request adapters:
|
||||
|
||||
- `openai-completions`
|
||||
- `openai-responses`
|
||||
- `anthropic-messages`
|
||||
- `ollama-generate`
|
||||
|
||||
@@ -95,12 +96,37 @@ The AI settings page uses:
|
||||
- `POST /api/v1/settings/integrations/ai-provider/connect`
|
||||
- `GET /api/v1/settings/integrations/ai-provider/secrets`
|
||||
- `GET /api/v1/settings/integrations/ai-provider/presets`
|
||||
- `POST /api/v1/settings/integrations/ai-provider/presets/{provider}/refresh`
|
||||
- `GET /api/v1/settings/ai-prompts`
|
||||
- `PUT /api/v1/settings/ai-prompts/{task_key}`
|
||||
- `POST /api/v1/settings/ai-prompts/{task_key}/reset`
|
||||
|
||||
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.
|
||||
|
||||
Model refresh and lightweight checks share `backend/app/services/llm_model_catalog.py` and query provider endpoints directly, without models.dev. Refresh uses the draft base URL, protocol and credentials, or saved configuration when no draft is supplied. Regional hosts and custom gateway paths are preserved. Missing required credentials return 400; upstream failures return safe errors and retain the previous catalog.
|
||||
|
||||
Successful catalogs and `refreshed_at` are stored under `llm_provider_preset:<provider>` in `system_settings`. Dynamic categories use the provider preset as their defaults and are validated before committing. Listing prefers saved catalogs and otherwise shows bundled suggestions. Refresh never modifies the active model, protocol, URL or credentials in `external_integrations`. The frontend retains the draft and previously loaded models when listing fails, with an explicit error message.
|
||||
|
||||
### Model Catalog Endpoints
|
||||
|
||||
These endpoints were checked against official documentation on 2026-09-13. The table lists discovery URLs; the form still takes the generation base URL. Credentials must belong to the configured service region.
|
||||
|
||||
| Provider | Model list GET endpoint | Authentication and parsing |
|
||||
| --- | --- | --- |
|
||||
| [MiniMax](https://platform.minimax.io/docs/api-reference/models/anthropic/list-models) | `https://api.minimaxi.com/anthropic/v1/models`; international: `api.minimax.io` | `x-api-key`; `data[].id`; `has_more/last_id` pagination |
|
||||
| [OpenAI](https://developers.openai.com/api/reference/resources/models/methods/list) | `https://api.openai.com/v1/models` | Bearer; `data[].id` |
|
||||
| [Anthropic](https://platform.claude.com/docs/en/api/models/list) | `https://api.anthropic.com/v1/models` | `x-api-key`, `anthropic-version`; paginate with `after_id` |
|
||||
| [DeepSeek](https://api-docs.deepseek.com/api/list-models) | `https://api.deepseek.com/v1/models` | Bearer; `data[].id` |
|
||||
| [Alibaba Model Studio](https://help.aliyun.com/zh/model-studio/list-models) | `/api/v1/models` on the configured regional host | Bearer; `output.models[].model`; `page_no/page_size/output.total`; filter `capabilities=TG` |
|
||||
| [Moonshot / Kimi](https://platform.kimi.ai/docs/api/list-models) | `https://api.moonshot.ai/v1/models`; China: `api.moonshot.cn` | Bearer; `data[].id` |
|
||||
| [OpenRouter](https://openrouter.ai/docs/api/api-reference/models/list-all-models-and-their-properties) | `https://openrouter.ai/api/v1/models` | Public catalog; Bearer when supplied; `data[].id` |
|
||||
| [OpenCode Go](https://opencode.ai/docs/go/#models) | `https://opencode.ai/zen/go/v1/models` | Public catalog; Bearer when supplied; `data[].id` |
|
||||
| [Ollama](https://docs.ollama.com/api/tags) | `/api/tags` on the configured server | Local servers need no key; `models[].model/name`; empty means no installed models |
|
||||
|
||||
New Model Studio endpoints for Beijing, Tokyo, Frankfurt and Virginia require the actual workspace host, such as `<WorkspaceId>.cn-beijing.maas.aliyuncs.com`. Singapore uses `dashscope-intl.aliyuncs.com`; Hong Kong uses `cn-hongkong.dashscope.aliyuncs.com`. Discovery changes only the path, never guesses a workspace or switches credential regions. If a legacy Beijing host no longer accepts the account, update the base URL from the provider console.
|
||||
|
||||
Pagination and retries have a 30-second overall deadline. Network failures and 502/503/504 allow up to two attempts; authentication failures are not retried. Models are sorted by supplied creation/release dates, preserving upstream order on ties, without arbitrary list truncation. Public catalog membership does not establish account-specific generation permission.
|
||||
|
||||
Admin keeps the AI page aligned with the legacy information architecture:
|
||||
|
||||
- `Model Providers`
|
||||
@@ -283,22 +309,11 @@ Tool keys follow the same rule. WebSearch and OCR must match the current tool an
|
||||
|
||||
### Lightweight Connectivity Testing
|
||||
|
||||
The Admin plug button performs a lightweight connectivity check and does not save configuration. Common API-platform practice is two-tiered:
|
||||
|
||||
- Check a provider catalog or low-cost endpoint to validate base URL, authentication, and model reachability.
|
||||
- Send full model requests only when the user explicitly runs Playground or a business task.
|
||||
|
||||
Connectivity results should be explicit:
|
||||
|
||||
- `ok`: authentication, route, and model catalog are usable.
|
||||
- `warning`: service is reachable, but the current model is missing from the catalog or capability metadata is incomplete.
|
||||
- `error`: authentication, network, protocol, or model lookup failed.
|
||||
|
||||
Toast titles must match the result; failures must not be titled as a successful connection.
|
||||
The Admin plug button checks proxy configuration and queries the current provider catalog. It returns `success=true` only when discovery succeeds and contains the selected model. A 404, authentication error, invalid response or missing model never becomes success based on bundled suggestions. The message distinguishes discovery from generation; Playground or a business task verifies actual generation. Testing never saves the draft or switches the default provider.
|
||||
|
||||
### OpenCode Go Routing Model
|
||||
|
||||
Subscription channels such as OpenCode Go should not be handled by hard-coded frontend model sets. Prefer provider catalog or backend capability discovery that records per-model capabilities such as `chat_completions`, `anthropic_messages`, `models_endpoint`, and whether a subscription key is required. The frontend should display capabilities; the backend should map provider, base URL, model, and adapter into the real request.
|
||||
OpenCode Go protocol mappings are owned by the backend catalog: MiniMax M3/M2.7/M2.5 and the verified Qwen3.6/3.7/3.8 models use Anthropic Messages; GPT-5.6 Luna, Grok 4.6 and Muse Spark Contributor use Responses; other supported models use Chat Completions. The official `/models` response currently supplies IDs only, so new models still require checking the documented endpoint table. Selecting a model updates its protocol in the draft, and backend mappings supersede known stale mappings. Responses uses `input`, `max_output_tokens` and `store=false`, and parses text and reasoning summaries. OpenCode calls carry an application User-Agent and a stable Playground conversation identifier. Generic `thinking=enabled` is translated to MiniMax M3’s `adaptive` format.
|
||||
|
||||
#### Settings Page Behavior
|
||||
|
||||
|
||||
@@ -111,6 +111,12 @@ Snapshot lists should not show every snapshot of the same collector as separate
|
||||
|
||||
Credential guides are maintained by `backend/app/services/credential_guides.py`. The console uses read / generate / reset actions to load or create Markdown instructions. The frontend should render the guide Markdown for operators, not expose generation prompts or raw metadata.
|
||||
|
||||
### News Live Stream Catalog
|
||||
|
||||
The IPTV-org adapter in `news_live_streams` retains its news-category filters. Its default `max_sources` is `0`, meaning all matching channels; an explicit positive value still limits collection. Existing configurations retaining the old `120` limit must change it to `0` and collect again to populate the complete catalog.
|
||||
|
||||
`GET /api/v1/tv/streams` uses database pagination in `tv_catalog.py`: `offset` defaults to `0`, `limit` defaults to `50` with a maximum of `100`, and whitespace-separated `q` terms match channel names, providers, regions, and languages. Built-in and configured sources come first. Collected sources are deduplicated by channel ID, exclude configured overrides, and are filtered, counted, sorted, and paginated in SQL. The response's `total` counts matches, while `source_count` counts the complete available catalog; use `has_more` and `next_offset` for subsequent pages. `selected_id` can also return the selected channel outside the current page without consuming its quota. `tv_streams.py` remains the owner of default and fallback sources.
|
||||
|
||||
## IV. Data Format (stored in CollectedData table)
|
||||
|
||||
```python
|
||||
@@ -352,7 +358,11 @@ GET /api/v1/visualization/vessels/{mmsi}/conflicts
|
||||
|
||||
`/api/v1/vessels/snapshot` requires `bbox` and `zoom`, and caps `limit` at `5000`. The Earth frontend uses a global bbox for current state and does not refetch on camera viewport changes. The endpoint reads `vessel_current_state` and reports `diagnostics.source = "vessel_current_state"`. The old `/api/v1/visualization/geo/vessels` route has been removed.
|
||||
|
||||
High-frequency AIS updates must not become per-delta full-layer rebuilds. If Earth uses the `/ws` `vessels` channel, it should send low-frequency reload/dirty hints and let the frontend merge snapshot refreshes. Tracks and conflicts still read historical facts through the single-vessel APIs.
|
||||
AISStream and BarentsWatch share the `/ws` `vessels` delta channel. Earth subscribes with `scope: "global"` on its existing WebSocket, without changing subscriptions as the camera moves. The backend coalesces notifications by MMSI for one second, then reads confirmed `vessel_current_state` rows. Frames contain at most 1000 items; further frames carry the remaining vessels rather than truncating them. Raw source messages must not overwrite confirmed client positions. Deleting current-state rows also produces MMSI-based remove notifications.
|
||||
|
||||
The frontend coalesces short bursts per MMSI and uses `Interactable.updateItems()` to update position and color buffers in place. Heading-bucket changes touch only affected buckets, growing capacity when needed. Existing marker identities, selections, and materials survive. Initial entry, reconnects, deletion hints, and the minute reconciliation still use snapshots without first clearing the layer. A bounded snapshot must not treat truncated vessels as deleted, and older responses must not overwrite newer stream updates or removals received while the request was in flight. Snapshots include query-start `generated_at`; it is compared with stream frame time to prevent stale cached snapshots from rolling back new vessels or removals.
|
||||
|
||||
Ordinary vessel writes use the `delta` strategy on `earth_updates`; while the dedicated channel is connected, they no longer request whole-layer reloads. Deletion or disconnected reconciliation uses `reload` while reusing existing objects. Hiding the layer unsubscribes and clears queued changes. Tracks, conflicts, and audit data continue through the single-vessel historical APIs.
|
||||
|
||||
### Layer APIs And Global Stats
|
||||
|
||||
|
||||
@@ -70,7 +70,10 @@ The unified event model is `earth.layer.changed`:
|
||||
| --- | --- |
|
||||
| `clear_then_reload` | Clear local frontend layer objects first, then force a refetch. Prefer this for deletes. |
|
||||
| `reload` | Keep old objects until fresh data returns. Use it for location, metadata, or non-destructive updates. |
|
||||
| `delta` | Used only for `earth_interactables`; upsert or remove objects by id. |
|
||||
| `delta` | `earth_interactables` upserts/removes by id; ordinary vessel writes update confirmed state by MMSI through the dedicated `vessels` channel without clearing the layer. |
|
||||
|
||||
Vessel deletion still emits a `reload` reconciliation hint; individual `vessel_current_state` deletions also enter the vessel remove channel. Source notifications identify changed MMSIs, while transmitted values come from current state. Global subscriptions use `scope: "global"`; message-size limits split frames instead of discarding remaining vessels.
|
||||
|
||||
|
||||
APIs must return HTTP 200 with an empty collection for real zero-data states; 5xx is reserved for real endpoint failures. After a delete event, if refetch fails, the frontend should keep the cleared state and show a lightweight error instead of restoring stale objects.
|
||||
|
||||
|
||||
@@ -321,7 +321,7 @@ The new vessel list entry point is no longer the legacy `/api/v1/visualization/g
|
||||
GET /api/v1/vessels/snapshot?bbox=-180,-85.05112878,180,85.05112878&zoom=12&limit=3000
|
||||
```
|
||||
|
||||
That endpoint reads `vessel_current_state`, returning the latest point per MMSI inside the freshness window. Raw `ais_raw_observations` remain available for tracks, audit, and situational analysis, but the display endpoint no longer scans and aggregates history on the fly. Earth sends a global bbox rather than the current camera viewport. If the `/ws` `vessels` channel is connected, it should act as a reload/dirty hint for merged refreshes, not as a per-AIS-delta full-layer rebuild path.
|
||||
That endpoint reads `vessel_current_state`, returning the latest point per MMSI inside the freshness window. Raw `ais_raw_observations` remain available for tracks, audit, and situational analysis, but the display endpoint no longer scans and aggregates history on the fly. Earth sends a global bbox rather than the current camera viewport. Realtime updates use the global `/ws` `vessels` subscription to deliver canonical state by MMSI and update existing markers in place. Larger updates are split across frames without dropping vessels. Initial load, reconnection, and deletion reconciliation still use snapshots; see [Collector Architecture](backend-collectors.md).
|
||||
|
||||
## Custom REST / WebSocket Mapping Runtime
|
||||
|
||||
|
||||
@@ -159,6 +159,8 @@ Each module is responsible for its own:
|
||||
|
||||
`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.
|
||||
|
||||
`tv-source-menu.js` reuses HUD and legend-list styling in a popover with search, a scrolling list, and a fixed count footer. `tv.js` requests 50 entries from `/api/v1/tv/streams` and caches channel details; search runs against the complete backend catalog, and pagination uses `next_offset`. Cancellation and a request generation counter prevent stale results from replacing a newer search. Catalog refresh uses `selected_id` to restore a channel outside the first page.
|
||||
|
||||
`brand.js` manages Earth HUD brand resources. Static assets provide the default brand; runtime overrides come from `/api/v1/earth/brand`, and uploaded images are served from `/earth-brand-assets/...`. The frontend must treat logo/title images and text fallback separately: if an image fails, show the text title; if text fields are empty, rely on backend defaults so the HUD brand area never renders blank. The console Earth Content page owns saving and resetting brand configuration; the Earth frontend only consumes it.
|
||||
|
||||
`about.js` manages the About card inside Earth settings. Frontend defaults remain as a fallback, while runtime content is loaded from `/api/v1/earth/about`. If the request fails or fields are missing, the renderer must fall back per field so the settings page never renders an empty card. Admin exposes an Earth Content `About` tab; saving uses `PUT /api/v1/earth/about`, and restoring defaults uses `DELETE /api/v1/earth/about`.
|
||||
@@ -179,6 +181,8 @@ The compute-center layer row has a notification badge for GeoJSON `unresolved` r
|
||||
|
||||
Location candidate state in the details card is cached in [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) by `entityType:entityId`. If the user closes the details card or unresolved queue and reopens the same compute center / BGP collector, previously collected candidates and status text are restored. Header-level `一键采用` prefers cached candidates, avoiding repeated online geocoding or LLM factcheck calls. After a location is saved, that entity's candidate list is cleared to a "refreshing layer" status so stale candidates do not keep misleading the user.
|
||||
|
||||
`runUnresolvedComputeCenterBatch()` owns a queue of entity contexts rather than panel DOM. `locationCollectStateCache` retains candidates, progress, and save results so rebuilt cards can hydrate the current state. `earth:compute-center-location-batch-change` updates the badge, `savedCandidate` excludes completed entries, and a ✓ entry remains after completion. Queue state lives only in the current Earth page: refresh, close, or route navigation interrupts unfinished work, while saved coordinates remain authoritative in the backend.
|
||||
|
||||
The `预览 / 保存` buttons on each candidate row use a single delegated `click` handler per candidate root (the `[data-collect-cache-key]` block in the details card, or `[data-unresolved-item]` in the unresolved queue), guarded by a `data-candidate-actions-bound` flag so it cannot be double-bound. Direct `pointerup` / `click` listeners on individual buttons and overlapping delegated handlers were removed. Candidate objects are no longer JSON-stringified into an HTML attribute and parsed back; buttons only carry `data-candidate-index`, and the handler resolves the candidate object from a module-level `Map` keyed by cache-key. This removes the entire class of failures caused by HTML entity escaping of `&` / `<` / `"` in candidate fields. Clicking `预览` dispatches `earth:preview-location-candidate`; `main.js`'s `previewLocationCandidate()` calls `showComputeCenterLocationPreview()`, which attaches a hollow breathing-ring sprite pair at the candidate coordinates (visually mirroring the BGP event ring) and focuses the camera on the candidate. Previewing another candidate replaces the ring; saving clears it and `spawnSavedComputeCenterLocation()` immediately spawns the formal compute-center interactable. Note that `main.js` has no module-level `earth` variable — every location-save / preview handler must call `const earth = getEarth();` first, otherwise the event handler throws a `ReferenceError` that the surrounding `.catch` swallows, producing the failure mode where the button "does nothing".
|
||||
|
||||
The `earth:compute-center-location-saved` reconciliation pipeline is deliberately silent on background-refresh failures. `spawnComputeCenterAfterLocationSave()` already presents the success toast and locked state; `refreshComputeCentersAfterLocationSave()` only reloads backend data when the scene is ready and no longer emits its own `已保存` toast. `handleComputeCenterLocationSaved()` runs refresh in the background after a successful spawn; only when spawn returns `null` (scene not ready) or throws does refresh take over the success toast. A refresh error is only `console.warn`'d — it must never surface as a `保存失败` message, because the save itself succeeded and the refresh is a follow-up sync.
|
||||
@@ -191,7 +195,11 @@ The backend snapshot endpoint still requires `bbox`, but the Earth runtime treat
|
||||
|
||||
Vessel markers are rendered through `createInteractableLayer()` as batched `THREE.Points`, with `cluster` and `avoidance` explicitly disabled. Dense waterways may overlap. Dragging or inertial rotation skips hover picking; normal hover uses screen-space nearest-point picking. Do not reconnect vessels to dynamic screen clustering or per-frame Points rebuilds, because those interaction costs are what make a 3000-marker layer feel heavy.
|
||||
|
||||
If the `/ws` `vessels` channel is used by Earth, it should be a low-frequency reload/dirty hint only. Do not create multiple viewport subscriptions, and do not turn every AIS delta into a full layer rebuild.
|
||||
AISStream and BarentsWatch share the `/ws` `vessels` delta channel. Earth subscribes with `scope: "global"` on its existing WebSocket, without changing subscriptions as the camera moves. The backend coalesces notifications by MMSI for one second, then reads confirmed `vessel_current_state` rows. Frames contain at most 1000 items; further frames carry the remaining vessels rather than truncating them. Raw source messages must not overwrite confirmed client positions. Deleting current-state rows also produces MMSI-based remove notifications.
|
||||
|
||||
The frontend coalesces short bursts per MMSI and uses `Interactable.updateItems()` to update position and color buffers in place. Heading-bucket changes touch only affected buckets, growing capacity when needed. Existing marker identities, selections, and materials survive. Initial entry, reconnects, deletion hints, and the minute reconciliation still use snapshots without first clearing the layer. A bounded snapshot must not treat truncated vessels as deleted, and older responses must not overwrite newer stream updates or removals received while the request was in flight. Snapshots include query-start `generated_at`; it is compared with stream frame time to prevent stale cached snapshots from rolling back new vessels or removals.
|
||||
|
||||
Ordinary vessel writes use the `delta` strategy on `earth_updates`; while the dedicated channel is connected, they no longer request whole-layer reloads. Deletion or disconnected reconciliation uses `reload` while reusing existing objects. Hiding the layer unsubscribes and clears queued changes. Tracks, conflicts, and audit data continue through the single-vessel historical APIs.
|
||||
|
||||
The legacy `/api/v1/visualization/geo/vessels` route has been removed. Frontend code should keep using `PATHS.vesselsApi` and can verify the current-state path through `diagnostics.source == "vessel_current_state"`.
|
||||
|
||||
@@ -221,6 +229,14 @@ The cruise sequencer handles generic logic: current target, queue order, camera
|
||||
|
||||
All material, layer, satellite, BGP, cable, terrain, celestial, and other style parameters are maintained here. Do not scatter magic numbers in module files.
|
||||
|
||||
## Render hot paths
|
||||
|
||||
`cable-batches.js` batches the complete cable and landing-point sets. `cables.js` still owns original business objects, picking, occlusion, and selection state. Do not make these interaction proxies draw individually again or return a render batch as the detail-card business object.
|
||||
|
||||
Satellite breathing runs in vertex shaders. `satellite-position-worker.js` moves full SGP4 position and initial trail calculations off the main thread; the main thread applies snapshots to interaction coordinates and render buffers. `satellite-propagation.js` supplies the same mathematics to the Worker, predicted orbits, and synchronous fallback. Reload, clear, and altitude changes must terminate prior work so stale snapshots cannot resurrect cleared layers.
|
||||
|
||||
The `i18n.js` MutationObserver translates only added subtrees, changed text, or changed attributes. Local clocks, status labels, and download progress must not synchronize all locale controls. Global locale synchronization belongs to initialization, locale changes, or subtrees containing new locale controls.
|
||||
|
||||
## Current Style Layers
|
||||
|
||||
CSS files in `frontend/public/earth/css/` each correspond to a specific component scope. Do not write global Earth styles into `base.css` unless they genuinely apply to everything.
|
||||
@@ -333,7 +349,7 @@ If future cable, satellite, or news cruise is added, do not copy a new set of `m
|
||||
|
||||
[controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) owns the Earth zoom state, and every zoom entry point must ultimately call `setZoomLevel()` to write the camera distance. Do not write `camera.position.z` from other modules, or the zoom percentage, drag sensitivity, and Interactable clustering thresholds will diverge again.
|
||||
|
||||
Interactable clustering is selected per layer through `cluster.strategy`. `stable-spherical` uses discrete zoom bands and local 3D bucket clustering, so BGP, compute centers, and Earth interactables do not regroup while the globe rotates inside the same band. `dynamic-screen` keeps the projection-based behavior for high-frequency realtime layers such as vessels, and `none` disables clustering. Stable cluster dots stay rigidly aligned to their 3D centroid projection and do not participate in 2D avoidance. See [Earth Interactable Clustering](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-clustering.md) for strategy configuration and tuning.
|
||||
Interactable clustering is selected per layer through `cluster.strategy`. `stable-spherical` uses discrete zoom bands and local 3D bucket clustering, so BGP, compute centers, and Earth interactables do not regroup while the globe rotates inside the same band. `dynamic-screen` keeps projection-based clustering, and `none` disables it. Vessels explicitly disable clustering and avoidance so incremental updates do not rebuild cluster topology. Stable cluster dots stay rigidly aligned to their 3D centroid projection and do not participate in 2D avoidance. See [Earth Interactable Clustering](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-clustering.md) for strategy configuration and tuning.
|
||||
|
||||
Wheel input has two paths. Traditional mouse wheels keep the 10% step and short animation, using `wheelZoomTarget` as the logical base for continuous wheel input. Trackpads and high-precision wheels use the pixel delta for continuous zoom and call `setZoomLevel()` directly instead of passing through the 10% stepped animation. The trackpad path also filters a short-window, old-direction residual delta after a real direction change so inertia tails do not pull a just-reversed zoom back in the previous direction.
|
||||
|
||||
|
||||
@@ -143,6 +143,8 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor
|
||||
|
||||
## Submarine Cables and Landing Points
|
||||
|
||||
`cable-batches.js` batches cable lines and landing points separately. The Sprite material and size parameters below remain owned by picking and selection proxies in `cables.js`; their color, opacity, scale, and visibility update the style texture or instance attributes. Drawing uses `LineSegments` and instanced billboards with the existing textures, colors, render order, and globe occlusion rules.
|
||||
|
||||
| Name | Variable | Current Value | Location / Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Default cable color | `CABLE_COLORS.default` | `0xffff44` | Used when no data color available |
|
||||
|
||||
@@ -20,7 +20,7 @@ Note: the layer control panel order and the registration / startup load order ar
|
||||
| 0.86 | Land/ocean base fill | `country-boundaries.js` | `landAltitudeOffset = 0.32`; ocean `#010609`, land `#080f1b` | Raycast disabled | Base map remains usable even when country borders are off; radius is separated from the base sphere to avoid far-zoom z-fighting. |
|
||||
| 0.96 | HD Earth texture | `earth.js` | `textureOverlayAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48` | Surface picking target when visible | HD texture always overlays the land/ocean base fill; radius must stay above the land/ocean base and far enough from the base sphere. |
|
||||
| 1 | Atmospheric glow and clouds | `earth.js` | Atmosphere / cloud spheres | Not in normal object selection path | Cloud layer controlled by the "Cloud Layer" toggle. |
|
||||
| 1 | Submarine cables | `cables.js` | `CABLE_CONFIG.line.renderOrder` | Cable picking path | Preserves existing cable layer level. |
|
||||
| 1 | Submarine cables / landing points | `cables.js`, `cable-batches.js` | All cable segments share one `LineSegments`; all landing points use instanced billboards; render order `1` and altitude offset `0.2` are unchanged | Original `Line` / `Sprite` objects retain per-item picking and selection state but no longer draw individually; landing-point sphere occlusion remains, with `depthTest: false` on the batch | Style textures and instance attributes preserve color, pulse, size, visibility, and click behavior. |
|
||||
| 1.2 | Real terrain | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` plus terrain displacement | Raycast disabled | Terrain overlays HD texture; temporarily hidden when HD texture is off, restores to prior state when re-enabled. |
|
||||
| 2.05 | Grid lines | `earth.js` | `CONFIG.earthRadius + 0.14` | Raycast disabled | Low-opacity lines over HD texture. |
|
||||
| 2.2 | Country borders | `country-boundaries.js` | `lineAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48`; claim lines have no extra lift | Raycast disabled | Line geometry still has its own `renderOrder`, but it shares the exact same radius as the HD texture shell to avoid parallax while the globe rotates. |
|
||||
@@ -36,6 +36,14 @@ Note: the layer control panel order and the registration / startup load order ar
|
||||
| 12+ | Satellite locked ring, halo, predicted orbit | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` and offsets; predicted orbit follows the same real-altitude toggle and fixes the lock-time globe pose to draw a closed inertial orbit; returns to same-sphere mode when real altitude is disabled | Satellite overlay path | Used for selected/locked satellite emphasis. |
|
||||
| 98-100 | Sun / moon halo and sprite | `celestial.js` | Fixed renderOrder | Celestial picking disabled | Foreground celestial sprites. |
|
||||
|
||||
## Full-set rendering and updates
|
||||
|
||||
- Batching changes GPU submission, not the number of satellites, cable segments, or landing points. It does not filter data by the visible hemisphere. Every adjacent cable vertex pair remains present, and picking still returns the original business object.
|
||||
- Satellite foreground and backdrop remain two `Points` draws. Vertex shaders compute breathing from static per-point parameters and a shared frame-time uniform; existing hover/locked state still controls point masking.
|
||||
- `satellite-position-worker.js` uses the same Three.js / SGP4 versions as the main thread. `satellite-propagation.js` owns shared orbit, display-altitude, and fallback calculations. Full positions and initial trail samples use transferable arrays; only one calculation is in flight. Reload, clear, and altitude-mode changes terminate the previous Worker.
|
||||
- Worker startup failure or `SATELLITE_CONFIG.workerStartupTimeoutMs` expiry falls back to the shared synchronous calculation. Counts, APIs, the existing update interval, and trail length are unchanged.
|
||||
- Verify full draw ranges, per-item picking, locked overlays, trails, rear-side occlusion, visibility toggles, and resource release after clearing. Frame-time comparisons require identical data, view, and resolution.
|
||||
|
||||
## Toggle Behavior
|
||||
|
||||
| Toggle | Behavior |
|
||||
|
||||
@@ -123,6 +123,9 @@ The default guide follows the BarentsWatch official tutorial and reminds you to
|
||||
|
||||
### AISStream Realtime Vessels
|
||||
|
||||
Once the vessel layer is open, subsequent AISStream and BarentsWatch positions update automatically. Selected vessels stay selected during updates, and reconnecting automatically reconciles the display without repeatedly toggling the layer.
|
||||
|
||||
|
||||
`AISStream Realtime Vessels` is the global AIS WebSocket collector. A passing connection test only confirms API key + endpoint format. Actual global vessel data requires the backend `aisstream_vessels` collector to stay connected and write to `ais_raw_observations`.
|
||||
|
||||
Steps:
|
||||
@@ -160,7 +163,11 @@ Providers and models accept presets or arbitrary custom IDs. Common fields:
|
||||
- Max Tokens, Anthropic Version: keep defaults if unsure
|
||||
- Timeout / Retry: timeout and retry attempts
|
||||
|
||||
The plug icon at the end of the Base URL input runs a connection test. A passing test echoes the model's short reply.
|
||||
The plug icon at the end of the Base URL input runs a lightweight connection check: it checks the proxy and model catalog, then confirms the selected model is listed. Use Playground afterward to verify generation access and the model's reply; the catalog check itself does not generate a response.
|
||||
|
||||
Use the refresh icon at the top right to update the available models. A successful refresh saves the catalog for later visits; catalogs with release dates show newer models first. Refresh preserves the current model, credentials, URLs, and unsaved edits. Select a model and click Save to change the model used by the application. A failed refresh displays an error and keeps the previous catalog.
|
||||
|
||||
Refresh uses the base URL, protocol, and API key currently entered in the form, so you can fill new configuration and refresh before saving. Credentialed providers require a key for the matching service region; an empty Ollama catalog means no models are installed. Selecting a model with a known protocol also updates the protocol field. Custom OpenAI gateways retain the manually selected protocol.
|
||||
|
||||
### Tools
|
||||
|
||||
@@ -282,6 +289,10 @@ AIS vessel legend colors by type: cargo, tanker, passenger, fishing, military, m
|
||||
|
||||
Search finds cables, landing points, satellites, compute centers, BGP events, BGP observers. Results jump to and focus the object.
|
||||
|
||||
### Choosing a News Live Stream
|
||||
|
||||
Open the live tab in the media panel and click the current channel to open the search menu. Search covers the complete catalog. The list loads 50 channels at a time and appends another page when you scroll to the bottom. A fixed footer shows the loaded and matching channel counts; a failed request can be retried below the list. The default source is Al Jazeera Mubasher, using its HLS playback URL. Other channels remain subject to availability of their playback service.
|
||||
|
||||
### Coordinate Candidate Collection
|
||||
|
||||
Compute center and BGP observer detail cards support automatic coordinate-candidate collection. Click the object then use "Collect Coordinate Candidates" or "Re-collect Coordinates". The backend assembles candidates from source coordinates, public-org registry APIs, and online geocoders. When regular sources have no candidate, the current default AI Provider runs one LLM factcheck fallback. BGP observers' stored coordinates only fill query context; they are not returned as candidates.
|
||||
@@ -298,6 +309,8 @@ Recommended single-object flow:
|
||||
|
||||
Adopt All is for batch processing the compute-center unresolved queue. It starts from the top and adopts the highest-confidence candidate. Records without factual support remain in the queue. When WebSearch is disabled, single locate and Adopt All are disabled because location validation depends on factual lookup.
|
||||
|
||||
The batch continues within the current Earth page when you close the candidate panel, inspect another object, or switch browser tabs. Reopening the queue restores progress and results. Saved entries are not collected again, and a ✓ entry beside the layer remains available after completion. Refreshing, closing, or navigating away from Earth interrupts unfinished work; coordinates already saved remain stored.
|
||||
|
||||
### Settings
|
||||
|
||||
The settings panel is grouped into Runtime, Display, Panels, Motion, Shortcuts, and System. It covers rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, hover tooltip, satellite idle breathing, real satellite altitude, track display, compact dots, day-night mode, panel toggles), motion debug mode / input source / skeleton-only / recognized-gesture whitelist, shortcut enablement and remapping, default globe size, terrain opacity, reset.
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## Background
|
||||
|
||||
Use `zsh ./planet.sh start --non-motion-agent` for daily startup; use `init` when preparing a new environment. When investigating latency, distinguish initial dependency downloads, container readiness, and application initialization using the stage timestamps.
|
||||
|
||||
Before preparing the AI Provider image, startup verifies the backend's actual database connection and recreates a missing port mapping once while preserving the volume. A terminated backend process or Uvicorn initialization, ASGI loading, import, or syntax failure stops waiting and identical retries immediately. Normally slow initialization keeps its existing timeout budget. AI Provider readiness probes the host `/health` endpoint directly, without waiting for Docker's first scheduled health check.
|
||||
|
||||
`planet.sh` manages start, stop, restart, health checks, and logs for all local services. The previous implementation had several startup issues:
|
||||
|
||||
1. AI Provider rebuilt every time, even when code had not changed.
|
||||
@@ -37,27 +41,9 @@ write_ai_provider_build_stamp() {
|
||||
}
|
||||
```
|
||||
|
||||
### Faster Fingerprint
|
||||
### Fingerprint Scope
|
||||
|
||||
The previous implementation tarred the whole `aiprovider/` directory before hashing, which could take seconds in large trees. The new version uses `find + stat` and reads only file metadata:
|
||||
|
||||
```bash
|
||||
compute_ai_provider_build_fingerprint() {
|
||||
find aiprovider \
|
||||
-type f \
|
||||
! -path '*/__pycache__/*' \
|
||||
! -name '.env' \
|
||||
! -name '.env.*' \
|
||||
! -name '*.pyc' \
|
||||
! -name '*.pyo' \
|
||||
| LC_ALL=C sort \
|
||||
| xargs -r stat --format="%Y %s %n" 2>/dev/null
|
||||
sha256sum docker-compose.yml docker-compose.simple.yml 2>/dev/null
|
||||
python3 "$SCRIPT_DIR/scripts/compute_aiprovider_dependency_fingerprint.py" 2>/dev/null
|
||||
}
|
||||
```
|
||||
|
||||
This is roughly 10 times faster for many-small-file workloads while preserving the same practical rebuild signal. `.env` and `.env.*` are excluded because runtime model, key, and Base URL changes should not force an image rebuild.
|
||||
The fingerprint hashes file contents from `aiprovider/`, the Dockerfile, the root manifest and lockfile, and the provider dependency information. It does not traverse frontend assets or downloaded data. `.env` and `.env.*` are excluded because they are runtime configuration. The Dockerfile applies its fingerprint label after dependency installation so a changed build marker alone does not invalidate dependency layers.
|
||||
|
||||
### Docker Build Context
|
||||
|
||||
@@ -83,13 +69,15 @@ The Dockerfile copies only AI Provider inputs:
|
||||
```dockerfile
|
||||
COPY pyproject.toml uv.lock /app/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev
|
||||
uv sync --frozen --only-group aiprovider
|
||||
|
||||
COPY aiprovider /app/aiprovider
|
||||
```
|
||||
|
||||
`uv sync` uses a BuildKit cache mount. The first build may still depend on network speed, but later builds reuse `/root/.cache/uv`.
|
||||
|
||||
The `aiprovider` dependency group in the root `pyproject.toml` uses the same `uv.lock` and installs only the API, HTTP client, settings, and ASGI runtime dependencies. The image excludes backend collectors and OpenCV / MediaPipe motion dependencies. The build fingerprint label comes after dependency installation and code copying, so a fingerprint change alone does not invalidate dependency layers. The container starts the installed `.venv/bin/python` directly, without runtime dependency synchronization. Dependency changes must update this group and the lockfile and validate image imports and `/health`.
|
||||
|
||||
### Runtime Configuration
|
||||
|
||||
Before starting AI Provider, `planet.sh` generates a current-user runtime env-file and passes it to Compose or the manual `docker run` fallback. The default path is `${XDG_STATE_HOME:-$HOME/.local/state}/planet/aiprovider_runtime.env`. Configuration priority:
|
||||
@@ -117,7 +105,7 @@ When the fingerprint matches, the script skips `docker compose build` and starts
|
||||
docker start planet_aiprovider
|
||||
```
|
||||
|
||||
`docker stop` stops the container without deleting the image. `cleanup_exit_containers` removes exited containers but not images, so the next `docker start` can reuse the existing image.
|
||||
`docker stop` stops the container without deleting the image. `start` and `restart` retain stopped containers instead of scanning and deleting all exited containers on the host. An unchanged AI Provider can be reused, while Compose still reconciles database configuration. Existing recreation paths remain responsible for image or configuration changes.
|
||||
|
||||
## Issue 2: Slow Port Cleanup
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ docker buildx version
|
||||
|
||||
## Database Initialization and Connection Checks
|
||||
|
||||
`init` reconciles PostgreSQL / Redis containers through Compose, including port configuration on existing containers. A plain `docker start` cannot apply configuration changes. Compose failures retain their specific errors, such as an occupied port, instead of falling back to an old container and reporting success.
|
||||
`init` and `start` reconcile PostgreSQL / Redis containers through Compose, including port configuration on existing containers. A plain `docker start` cannot apply configuration changes. Compose failures retain their specific errors, such as an occupied port, instead of falling back to an old container and reporting success.
|
||||
|
||||
The container's `pg_isready` check only establishes that the server accepts connections; it does not validate the host backend's address and credentials. Once containers are healthy, `init` runs `scripts/check_database_connection.py` using the backend's effective `DATABASE_URL`. It checks the local PostgreSQL published port and executes a read-only `SELECT 1` before reporting database readiness or creating tables and seed data.
|
||||
The container's `pg_isready` check only establishes that the server accepts connections; it does not validate the host backend's address and credentials. Once containers are healthy, both initialization and backend startup run `scripts/check_database_connection.py` using the backend's effective `DATABASE_URL`. It checks the local PostgreSQL published port and executes a read-only `SELECT 1`. Startup performs this check before preparing the AI Provider image and stops immediately on failure. Initialization only creates tables and seed data after the check passes.
|
||||
|
||||
- If the actual local port mapping is still missing or mismatched, the script recreates PostgreSQL once from Compose while preserving its data volume, then checks again. A second failure stops initialization.
|
||||
- Authentication, database-name, and network failures stop before schema changes. Diagnostics show the host, port, and database name without passwords, full connection strings, or raw driver exceptions.
|
||||
|
||||
@@ -33,7 +33,7 @@ The default role is `viewer`: you can sign in but only see public pages. For col
|
||||
After landing on the `/admin` dashboard, here's a recommended walk-through:
|
||||
|
||||
1. `/collection-management?section=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
|
||||
2. `/ai?section=integrations`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional
|
||||
2. `/ai?section=integrations`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to check the model catalog; verify generation in Playground. The refresh icon updates and saves the available model catalog while preserving your form; select a model and click Save to apply it. WebSearch / OCR tools are optional
|
||||
3. `/earth-content?section=brand`: maintain the Earth HUD logo and title image in Branding. The Upload button inside each URL field opens a file picker, and image files can also be dropped directly onto the matching field. Save the brand configuration after upload
|
||||
4. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Built-in Sources` for finite collectors: with no rows selected, click `Trigger All`; after selecting rows, the primary button becomes `Trigger Selected N`. The top-right queue button shows progress. Use `/datasources -> Realtime Sources` for AISStream / WebSocket health and counters
|
||||
5. `/alerts/system`: verify system alerts look right
|
||||
@@ -50,6 +50,9 @@ Once in, verify:
|
||||
- The globe renders, and the right-side layer panel can toggle layers
|
||||
- Search finds cables, satellites, compute centers, BGP events
|
||||
- Compute-center and BGP collector detail cards can collect coordinate candidates and preview them on Earth
|
||||
- Close the candidate panel or switch browser tabs during batch location, then return to see progress; refreshing or leaving Earth interrupts unfinished work
|
||||
- The vessel layer receives ongoing AISStream / BarentsWatch positions and reconciles after reconnecting
|
||||
- The live-stream menu searches the complete channel catalog and loads more on scroll; Al Jazeera Mubasher is the default
|
||||
- Mouse drag, wheel zoom, and the zoom percentage indicator work
|
||||
- The settings panel can switch rotate / cruise / motion modes; motion settings can select the input source and allowed gestures; view settings can switch hover tooltip content, and satellite settings can toggle real-altitude layering and track display
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
支持的请求适配器:
|
||||
|
||||
- `openai-completions`
|
||||
- `openai-responses`
|
||||
- `anthropic-messages`
|
||||
- `ollama-generate`
|
||||
|
||||
@@ -95,12 +96,37 @@ AI 配置页使用的接口:
|
||||
- `POST /api/v1/settings/integrations/ai-provider/connect`
|
||||
- `GET /api/v1/settings/integrations/ai-provider/secrets`
|
||||
- `GET /api/v1/settings/integrations/ai-provider/presets`
|
||||
- `POST /api/v1/settings/integrations/ai-provider/presets/{provider}/refresh`
|
||||
- `GET /api/v1/settings/ai-prompts`
|
||||
- `PUT /api/v1/settings/ai-prompts/{task_key}`
|
||||
- `POST /api/v1/settings/ai-prompts/{task_key}/reset`
|
||||
|
||||
这些接口都需要用户登录。`secrets` 接口只用于配置页点击显示 key/token 时取回明文,隐藏时前端恢复为脱敏预览。
|
||||
|
||||
模型目录刷新和轻量连通性测试共用 `backend/app/services/llm_model_catalog.py`,直接请求当前供应商的模型接口,不再依赖 models.dev。使用当前表单草稿中的基础地址、协议和凭证;不传草稿时读取已保存配置。国内、国际和自定义网关地址保持各自的地域与路径。缺少必需凭证时返回 400 并提示配置 API Key;上游超时、鉴权或响应错误返回安全提示,并保留上次目录。
|
||||
|
||||
成功目录和 `refreshed_at` 按供应商保存到 `system_settings` 的 `llm_provider_preset:<provider>` 分类。该动态分类使用对应供应商预设作为默认值,提交前校验分类,避免已提交却返回失败。列表优先返回已保存目录,未刷新过的供应商显示内置建议。刷新不修改 `external_integrations` 的当前模型、协议、地址或凭证;页面保留草稿,目录加载失败时保留已显示模型并明确提示失败。
|
||||
|
||||
### 模型目录接口
|
||||
|
||||
以下接口于 2026-09-13 对照官方文档核对。表中的 URL 是模型列表地址,配置表单仍填写生成接口的基础地址。需要凭证的目录必须使用对应服务地域的 API Key。
|
||||
|
||||
| 供应商 | 模型列表 GET 接口 | 鉴权与解析 |
|
||||
| --- | --- | --- |
|
||||
| [MiniMax](https://platform.minimax.io/docs/api-reference/models/anthropic/list-models) | `https://api.minimaxi.com/anthropic/v1/models`;国际站使用 `api.minimax.io` | `x-api-key`;`data[].id`;处理 `has_more/last_id` |
|
||||
| [OpenAI](https://developers.openai.com/api/reference/resources/models/methods/list) | `https://api.openai.com/v1/models` | Bearer;`data[].id` |
|
||||
| [Anthropic](https://platform.claude.com/docs/en/api/models/list) | `https://api.anthropic.com/v1/models` | `x-api-key`、`anthropic-version`;按 `after_id` 翻页 |
|
||||
| [DeepSeek](https://api-docs.deepseek.com/api/list-models) | `https://api.deepseek.com/v1/models` | Bearer;`data[].id` |
|
||||
| [阿里百炼](https://help.aliyun.com/zh/model-studio/list-models) | 同地域主机的 `/api/v1/models` | Bearer;`output.models[].model`;按 `page_no/page_size/output.total` 翻页,筛选 `capabilities=TG` |
|
||||
| [Moonshot / Kimi](https://platform.kimi.ai/docs/api/list-models) | `https://api.moonshot.ai/v1/models`;国内站使用 `api.moonshot.cn` | Bearer;`data[].id` |
|
||||
| [OpenRouter](https://openrouter.ai/docs/api/api-reference/models/list-all-models-and-their-properties) | `https://openrouter.ai/api/v1/models` | 支持公共目录;有凭证时附带 Bearer;`data[].id` |
|
||||
| [OpenCode Go](https://opencode.ai/docs/go/#models) | `https://opencode.ai/zen/go/v1/models` | 支持公共目录;有凭证时附带 Bearer;`data[].id` |
|
||||
| [Ollama](https://docs.ollama.com/api/tags) | 本机或配置服务器的 `/api/tags` | 本地无需 Key;`models[].model/name`;空目录表示尚未安装模型 |
|
||||
|
||||
百炼的北京、东京、法兰克福、弗吉尼亚新接口要求实际业务空间主机,例如 `<WorkspaceId>.cn-beijing.maas.aliyuncs.com`;新加坡使用 `dashscope-intl.aliyuncs.com`,香港使用 `cn-hongkong.dashscope.aliyuncs.com`。系统只替换目录路径,不猜测业务空间或跨地域切换凭证。旧北京域名若不再接受账号凭证,应按控制台给出的业务空间地址更新基础地址。
|
||||
|
||||
完整分页和重试受 30 秒总超时保护;网络故障及 502/503/504 最多尝试两次,鉴权失败不重试。按接口提供的创建/发布日期倒序排列;日期相同时保留上游顺序,不截断为固定数量。公共目录只表示供应商公开的模型集合,不代表账号已获得每个模型的生成权限。
|
||||
|
||||
Admin 的 AI 页面按业务信息架构组织为:
|
||||
|
||||
- `模型供应商`
|
||||
@@ -283,22 +309,11 @@ Admin 的密钥状态必须按 provider / tool 精确判断:
|
||||
|
||||
### 轻量连通性测试
|
||||
|
||||
Admin 的插头按钮走轻量连通性测试,不承担保存职责。业界常见做法是分两层:
|
||||
|
||||
- 快速检查 provider 目录或低成本 endpoint,确认 base URL、鉴权和当前模型是否可达。
|
||||
- 只有在用户明确运行 Playground 或业务任务时才发完整模型请求。
|
||||
|
||||
因此,连通性测试应尽量使用低成本请求,并返回明确状态:
|
||||
|
||||
- `ok`: 鉴权、路由和模型目录可用。
|
||||
- `warning`: 服务可达,但当前模型不在目录或能力声明不完整。
|
||||
- `error`: 鉴权失败、网络失败、协议错误或模型不可用。
|
||||
|
||||
错误 toast 标题必须和结果一致,不能在失败时显示“连通性正常”。
|
||||
Admin 插头按钮检查代理服务配置,再查询当前供应商目录;只有目录请求成功且包含所选模型时才返回 `success=true`。404、鉴权失败、无效响应和缺失模型都不会因命中内置建议而被改判成功。提示明确区分“找到模型”和“实际生成成功”;完整调用由 Playground 或业务任务验证。测试不保存表单或切换默认供应商。
|
||||
|
||||
### OpenCode Go 路由模型
|
||||
|
||||
OpenCode Go 这类订阅通道不要靠前端硬编码模型集合判断协议。推荐在 provider catalog 或后端能力发现中记录每个模型的协议能力,例如 `chat_completions`、`anthropic_messages`、`models_endpoint` 和是否需要订阅 key。前端只展示能力结果;后端负责把 provider、base URL、model 和协议适配映射为实际请求。
|
||||
OpenCode Go 的模型协议映射由后端目录集中维护:MiniMax M3/M2.7/M2.5 和已核对的 Qwen3.6/3.7/3.8 模型使用 Anthropic Messages;GPT-5.6 Luna、Grok 4.6 和 Muse Spark Contributor 使用 Responses;其余已支持模型使用 Chat Completions。官方 `/models` 当前只提供模型 ID,新增模型的协议仍需对照官方端点表核对。前端选择模型时同步对应协议,后端覆盖旧的已知错误映射。Responses 请求使用 `input`、`max_output_tokens` 和 `store=false`;回复解析文本及推理摘要。OpenCode 请求携带应用 User-Agent 和会话标识;Playground 同一会话保持标识稳定。MiniMax M3 的通用 `thinking=enabled` 转为官方 `adaptive` 格式。
|
||||
|
||||
#### 配置页行为
|
||||
|
||||
|
||||
@@ -102,6 +102,12 @@ AIS 船只类采集器和其它 `CollectedData` 采集器的落库路径不同
|
||||
|
||||
TOP500 和 Epoch AI 算力数据的公开源不总是提供可用经纬度。Earth 统一算力中心接口在主地图启动链路中只使用源数据自带坐标或 `compute_center_locations` 维表坐标;缺少坐标的记录会进入 `unresolved`,不会通过本地注册表、国家质心或猜测城市自动渲染。用户手动采集候选时,后端会用源字段调用 ROR 组织注册 API 和 Nominatim/OpenStreetMap 在线搜索;候选经前端保存后写入 `compute_center_locations`,后续地图刷新再从维表渲染。
|
||||
|
||||
### 新闻直播频道目录
|
||||
|
||||
`news_live_streams` 的 IPTV-org 适配器保留现有新闻分类筛选,`max_sources` 默认值为 `0`,表示不截断匹配频道;显式正数仍限制采集数量。已有配置若保留旧的 `120`,需要改为 `0` 并重新采集才能补齐目录。
|
||||
|
||||
`GET /api/v1/tv/streams` 由 `tv_catalog.py` 提供数据库分页:`offset` 默认 `0`,`limit` 默认 `50`、最大 `100`,`q` 按空白拆词并匹配频道名、来源、地区和语言。内置及配置源优先,采集源按频道标识去重并排除配置覆盖项,再在数据库中搜索、计数、排序和分页,避免全表读取后切片。响应的 `total` 是匹配总数,`source_count` 是完整可用目录数量;后续页使用 `next_offset` 和 `has_more`。`selected_id` 可额外取得不在当前页的已选频道,不占本页配额。默认和兜底源继续由 `tv_streams.py` 统一管理。
|
||||
|
||||
## 四、数据格式 (统一存储到 CollectedData 表)
|
||||
|
||||
```python
|
||||
@@ -382,7 +388,11 @@ GET /api/v1/visualization/vessels/{mmsi}/conflicts
|
||||
|
||||
`/api/v1/vessels/snapshot` 必须携带 `bbox` 和 `zoom`,后端最大 `limit=5000`。Earth 前端使用全球 bbox 读取当前状态,不随相机视口变化反复请求。接口消费 `vessel_current_state`,并在 `diagnostics.source` 返回 `vessel_current_state`;旧 `/api/v1/visualization/geo/vessels` 路由已移除。
|
||||
|
||||
高频 AIS 更新不要直接推送成每条 delta 的整层重建。`/ws` 的 `vessels` channel 如用于 Earth,应广播低频 reload/dirty 提示,由前端合并刷新 snapshot;轨迹和冲突详情仍按单船接口读取历史事实。
|
||||
AISStream 与 BarentsWatch 共用 `/ws` 的 `vessels` 增量通道。Earth 使用现有 WebSocket 连接订阅 `scope: "global"`,不跟随镜头改变订阅范围。后端按 MMSI 合并一秒内的通知,再读取 `vessel_current_state` 的已确认状态;单帧最多 1000 项,超过时继续发送后续帧,不截断不同船只。原始源消息不能直接覆盖客户端的确认位置。当前状态删除也会产生按 MMSI 的 remove 通知。
|
||||
|
||||
前端将短时间内同一 MMSI 的变更合并为最新值,通过 `Interactable.updateItems()` 原位修改位置和颜色缓冲。航向分桶变更只更新受影响的桶,容量不足才扩容;保留已有 marker 身份、锁定状态和材质。首次进入、重连、删除提示及每分钟巡检仍用 snapshot 校准,但不先清空整个图层。收到有数量上限的快照时,不能把被截断的船只当成删除;快照返回期间到达的较新更新和删除也不能被旧响应覆盖。快照携带查询开始时的 `generated_at`,与实时帧时间一起用于识别缓存旧快照,避免新船或删除状态被回滚。
|
||||
|
||||
`earth_updates` 中船舶普通写入的策略是 `delta`;专用通道连通时不再触发整层重拉。删除或断连后的校准使用 `reload`,并复用现有对象。关闭图层会取消船舶订阅并清空待应用变更。轨迹、冲突详情和审计继续按单船接口读取历史事实。
|
||||
|
||||
### 图层接口与全量统计分离
|
||||
|
||||
|
||||
@@ -70,7 +70,10 @@ DB 变化不再默认创建 `earth_refresh` 任务,因此不会被同 source
|
||||
| --- | --- |
|
||||
| `clear_then_reload` | 先清前端本地图层对象,再强制重拉接口。删除数据时优先使用。 |
|
||||
| `reload` | 保留旧对象直到新数据返回,适合定位、元数据或非破坏性更新。 |
|
||||
| `delta` | 只用于 `earth_interactables`,按 id upsert 或 remove。 |
|
||||
| `delta` | `earth_interactables` 按 id upsert/remove;船舶普通写入由专用 `vessels` 通道按 MMSI 更新确认状态,不触发整层清空。 |
|
||||
|
||||
船舶删除仍发出 `reload` 校准提示,`vessel_current_state` 的逐项删除同时进入船舶 remove 通道。源通知只决定哪些 MMSI 需要更新,推送值以当前状态表为准。全局订阅使用 `scope: "global"`;消息大小上限用于拆包,不用于丢弃其余船只。
|
||||
|
||||
|
||||
接口在真实 0 数据时必须返回 200 和空集合;只有真实接口异常才返回 5xx。前端收到删除事件后,如果重拉失败,应保持已清空状态并显示轻量错误,不恢复旧对象。
|
||||
|
||||
|
||||
@@ -323,7 +323,7 @@ AISStream 使用 WebSocket 实时流,采集器只写入 `ais_raw_observations`
|
||||
GET /api/v1/vessels/snapshot?bbox=-180,-85.05112878,180,85.05112878&zoom=12&limit=3000
|
||||
```
|
||||
|
||||
该接口查询 `vessel_current_state` 当前状态表,只返回有效窗口内每个 MMSI 的最新点;原始 `ais_raw_observations` 继续保留给轨迹、审计和态势分析,但不再由展示接口临时扫描聚合。Earth 前端统一传全球 bbox,不随当前镜头视口反复请求。实时更新如接入 `/ws` 的 `vessels` channel,应作为 reload/dirty 提示触发合并刷新,不能把每条 AIS delta 直接变成整层重建。
|
||||
该接口查询 `vessel_current_state` 当前状态表,只返回有效窗口内每个 MMSI 的最新点;原始 `ais_raw_observations` 继续保留给轨迹、审计和态势分析,但不再由展示接口临时扫描聚合。Earth 前端统一传全球 bbox,不随当前镜头视口反复请求。实时更新使用 `/ws` 的 `vessels` 全局订阅,按 MMSI 推送确认状态并原位更新;超出单帧上限时拆包,保留所有更新。首次加载、重连和删除校准仍使用快照,详见[采集器架构](backend-collectors.md)。
|
||||
|
||||
## 自定义 REST / WebSocket 映射运行时
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ React 路由入口:
|
||||
- 各图层集成
|
||||
- Earth 级别状态同步
|
||||
|
||||
Earth 收到 `/ws` 的 `earth_updates` 时只把它当作刷新提示,真实数据仍通过 `/api/v1/visualization/...` 接口重新 GET。数据库驱动的刷新由后端 listener 直接清理缓存再广播,不再默认经过 `earth_refresh` 作业队列;前端收到 `database_changed` 后会按 layer 读取 `clear_then_reload`、`reload` 或 `delta` 策略。`clear_then_reload` 必须先清 Three.js 对象再 no-store 重拉,summary 只做一致性校验,不能用 `0` 作为跳过图层重拉的理由。技术链路见 [数据作业与 Outbox 技术架构](/home/ray/dev/linkong/planet/docs/technical/zh/data-job-earth-sync-architecture.md),业务数据流见 [业务架构与数据流转](/home/ray/dev/linkong/planet/docs/technical/zh/platform-data-flows.md)。
|
||||
Earth 收到 `/ws` 的 `earth_updates` 时只把它当作刷新提示,普通图层仍通过 `/api/v1/visualization/...` 接口重新 GET;船舶使用下文的 `vessels` 专用确认状态增量通道。数据库驱动的刷新由后端 listener 直接清理缓存再广播,不再默认经过 `earth_refresh` 作业队列;前端收到 `database_changed` 后会按 layer 读取 `clear_then_reload`、`reload` 或 `delta` 策略。`clear_then_reload` 必须先清 Three.js 对象再 no-store 重拉,summary 只做一致性校验,不能用 `0` 作为跳过图层重拉的理由。技术链路见 [数据作业与 Outbox 技术架构](/home/ray/dev/linkong/planet/docs/technical/zh/data-job-earth-sync-architecture.md),业务数据流见 [业务架构与数据流转](/home/ray/dev/linkong/planet/docs/technical/zh/platform-data-flows.md)。
|
||||
|
||||
### 3. 地球控制层
|
||||
|
||||
@@ -170,6 +170,8 @@ Browser Camera provider 的手势识别管线在 [motion-browser-provider.js](/h
|
||||
|
||||
`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`,不会覆盖用户偏好。
|
||||
|
||||
`tv-source-menu.js` 复用 HUD 面板与图例列表样式,使用 popover 显示搜索、滚动列表和固定计数栏。`tv.js` 按 50 条请求 `/api/v1/tv/streams`,维护当前频道缓存;搜索交给后端完整目录,翻页用 `next_offset`。菜单通过取消请求和请求代数屏蔽过期响应,不能让旧搜索覆盖新输入。刷新目录时通过 `selected_id` 恢复不在第一页的频道。
|
||||
|
||||
`brand.js` 管理智能星球 HUD 品牌资源。默认品牌来自静态资源,运行时覆盖值来自 `/api/v1/earth/brand`,上传的图片通过 `/earth-brand-assets/...` 读取。前端必须把 logo/title 图片和文本 fallback 分开处理:图片加载失败时显示文本标题,文本字段为空时使用后端默认值,避免 HUD 品牌区空白。控制台的智能星球内容页负责保存和重置品牌配置,智能星球前端只消费结果。
|
||||
|
||||
`about.js` 管理智能星球设置里的“关于”卡片。默认内容仍保留在前端作为兜底,运行时优先读取 `/api/v1/earth/about`。接口失败或字段缺失时必须回退默认值,避免设置页出现空白。控制台的智能星球内容页提供“关于”tab,保存走 `PUT /api/v1/earth/about`,恢复默认走 `DELETE /api/v1/earth/about`。
|
||||
@@ -209,6 +211,14 @@ TV 预览需要尽量复用 Earth 运行时的直播卡片结构和状态标签
|
||||
|
||||
新闻巡航摘要的未来计划保存在仓库路径 `docs/plans/earth-news-cruise-summary-plan.md`,不作为公开 Docs 页面入口。
|
||||
|
||||
## 渲染热路径
|
||||
|
||||
`cable-batches.js` 将全部海缆线段与全部登陆点分别合批绘制;`cables.js` 继续拥有原始业务对象、拾取、遮挡和选择状态。避免在后续功能中把这些代理对象重新加入逐对象绘制,或把绘制批次当作业务对象返回给详情卡。
|
||||
|
||||
卫星的逐点呼吸在 GPU 中计算。全量 SGP4 位置和初始轨迹计算通过 `satellite-position-worker.js` 离开主线程;主线程只接收位置快照、更新共享交互坐标及绘制缓冲。轨道数学由 `satellite-propagation.js` 同时供 Worker、预测轨道和同步降级路径使用,避免多套公式漂移。数据重载、清空和高度切换必须同步终止旧计算,不能让旧快照恢复已清空的图层。
|
||||
|
||||
`i18n.js` 的 MutationObserver 只翻译新增子树、变动文本或变动属性。局部时钟、状态和下载进度更新不能触发整页语言控件同步;全局语言同步只属于初始化、语言切换或包含新语言控件的子树。
|
||||
|
||||
## 当前样式分层
|
||||
|
||||
Earth 的 CSS 不是一份大样式表,而是分层管理:
|
||||
@@ -344,7 +354,13 @@ AIS 船只图层入口:
|
||||
|
||||
AISStream 的 `PositionReport` 常带实时位置和 `MetaData.ShipName`,但船型通常来自低频 `ShipStaticData.Type`。后端会把 `MetaData.ShipName` 补进船名,并将类型码映射为 Cargo / Tanker / Passenger / Fishing / Military;仍缺失的船型需要等待静态 AIS 消息或后续船舶资料 enrichment,不能在前端凭颜色之外的信息臆造细分类。
|
||||
|
||||
旧 `/api/v1/visualization/geo/vessels` 路由已移除。前端打开船只图层时只应拉取一次全局 `/api/v1/vessels/snapshot`;API 参数里的 bbox 是后端接口约束,Earth 运行时传全球范围,不表示当前镜头视口。`/ws` 的 `vessels` channel 如启用,只作为低频 reload/dirty 提示,不能把每条 AIS delta 直接变成整层重建。后端通过 `diagnostics.source == "vessel_current_state"` 暴露当前状态链路。
|
||||
旧 `/api/v1/visualization/geo/vessels` 路由已移除;初始数据仍通过全球 bbox 的 `/api/v1/vessels/snapshot` 读取。
|
||||
|
||||
AISStream 与 BarentsWatch 共用 `/ws` 的 `vessels` 增量通道。Earth 使用现有 WebSocket 连接订阅 `scope: "global"`,不跟随镜头改变订阅范围。后端按 MMSI 合并一秒内的通知,再读取 `vessel_current_state` 的已确认状态;单帧最多 1000 项,超过时继续发送后续帧,不截断不同船只。原始源消息不能直接覆盖客户端的确认位置。当前状态删除也会产生按 MMSI 的 remove 通知。
|
||||
|
||||
前端将短时间内同一 MMSI 的变更合并为最新值,通过 `Interactable.updateItems()` 原位修改位置和颜色缓冲。航向分桶变更只更新受影响的桶,容量不足才扩容;保留已有 marker 身份、锁定状态和材质。首次进入、重连、删除提示及每分钟巡检仍用 snapshot 校准,但不先清空整个图层。收到有数量上限的快照时,不能把被截断的船只当成删除;快照返回期间到达的较新更新和删除也不能被旧响应覆盖。快照携带查询开始时的 `generated_at`,与实时帧时间一起用于识别缓存旧快照,避免新船或删除状态被回滚。
|
||||
|
||||
`earth_updates` 中船舶普通写入的策略是 `delta`;专用通道连通时不再触发整层重拉。删除或断连后的校准使用 `reload`,并复用现有对象。关闭图层会取消船舶订阅并清空待应用变更。轨迹、冲突详情和审计继续按单船接口读取历史事实。
|
||||
|
||||
新的图层接口族是 `/api/v1/layers/*`,用于把地图渲染数据和聚合面板统计分开。地图层请求必须带 `bbox`、`zoom` 和受控 `limit`,响应会返回 `visible_count`、`returned_count` 和 `diagnostics`,其中 `degraded/truncated/limit_clamped` 用于前端提示降级。右侧聚合统计不要从图层响应累加,应读取 `/api/v1/data-products` 或 `/api/v1/data-products/{product_id}/status`,因为这些统计保持全量/全局口径,不随当前视口变化。
|
||||
|
||||
@@ -380,6 +396,8 @@ AISStream 的 `PositionReport` 常带实时位置和 `MetaData.ShipName`,但
|
||||
|
||||
详情卡里的坐标候选状态由 [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) 按 `entityType:entityId` 缓存在模块内存中。用户关闭详情卡或待定位列表后再次打开同一个算力中心 / BGP 观测站,已经采集到的候选和状态文案会恢复;`一键采用` 会优先使用缓存候选,避免重复调用在线地理编码或 LLM factcheck。保存成功后该实体的候选列表会清空为“正在刷新图层”状态,避免旧候选在刷新后继续误导用户。
|
||||
|
||||
批量定位由 `runUnresolvedComputeCenterBatch()` 持有实体上下文队列,不持有面板 DOM;`locationCollectStateCache` 保存候选、进度和保存结果,重建卡片时重新 hydrate。`earth:compute-center-location-batch-change` 同步任务状态与气泡入口,成功条目根据 `savedCandidate` 排除,全部完成后保留 ✓ 入口。状态只存在当前 Earth 页面内存中,刷新、关闭或路由离开会终止未完成队列;已保存坐标仍以后端为准。
|
||||
|
||||
候选行的 `预览 / 保存` 按钮采用单一的事件委托模型:每个候选根(详情卡里的 `[data-collect-cache-key]` 块,或待定位列表里的 `[data-unresolved-item]`)只挂一个 `click` 监听,由 `data-candidate-actions-bound` 幂等标记,不再混用 `pointerup` / `click` 直绑或重复委托。候选对象不再以 JSON 字符串塞进 HTML 属性后再 `JSON.parse`,按钮只携带 `data-candidate-index`,handler 通过 cache-key 在模块内存的 `Map` 里取出原对象,避开 HTML 实体转义对 `&` / `<` / `"` 的破坏。点击 `预览` 会派发 `earth:preview-location-candidate`,由 `main.js` 的 `previewLocationCandidate()` 调用 `showComputeCenterLocationPreview()`:在候选经纬度上挂双层空心呼吸 sprite(视觉参考 BGP 事件 ring),并把视角聚焦到候选坐标;切换到另一个候选会替换为新呼吸圈,保存时立即清除并由 `spawnSavedComputeCenterLocation()` 即时生成正式算力中心交互图标。注意 `main.js` 没有模块级 `earth` 变量,所有 location-save / preview 处理函数必须先 `const earth = getEarth();`,否则会在事件 handler 里抛 `ReferenceError` 被 `.catch` 静默掉,外观上等同于按钮“没有反应”。
|
||||
|
||||
`earth:compute-center-location-saved` 之后的图层校准链路对后台刷新失败保持沉默:`spawnComputeCenterAfterLocationSave()` 已经把 toast 和 locked 状态都给了用户,`refreshComputeCentersAfterLocationSave()` 只在场景就绪时重新拉取后端数据,本身不再吐 `已保存` toast;`handleComputeCenterLocationSaved()` 在 spawn 成功路径让 refresh 静默后台运行,只在 spawn 返回 `null`(场景未就绪)或抛错时才让 refresh 接管成功 toast,refresh 自身报错只走 `console.warn`,绝不冒泡成 `保存失败` 文案——保存请求本身已经成功,刷新失败属于后续同步问题。
|
||||
@@ -392,7 +410,7 @@ asset 图标大小由 `Interactable` 的 `icon.fitSize` 控制。SVG / 图片文
|
||||
|
||||
跨 Interactable 的同坐标关系也在公共层记录,但真实位置必须始终以 `icon_base_position` 为准。缩放、避让、聚合和后续 spiderfy 展开都只能改变屏幕表现,不能写回 `marker.position` 或 `THREE.Points` 里的业务锚点;巡航定位、详情卡、搜索定位和 picking 返回对象都必须落回真实经纬度。多个图标归入同一个经纬度 key 时,公共层只写 `icon_avoidance_*` 元数据,供业务层弱化 halo 或显示聚合提示;真正的低缩放聚合应通过独立 cluster glyph / screen layout 层实现,而不是把对象沿地表切平面挪开。
|
||||
|
||||
`Interactable` 的单点显示只由全局地图缩放决定:170% 及以下强制显示小圆点,超过 170% 显示原图标。cluster 现在由 `cluster.strategy` 决定:`stable-spherical` 使用离散 zoom band 和 3D 球面分桶,BGP、算力中心和 Earth interactable 在同一 band 内旋转或细微缩放时不会重新计算聚合拓扑;`dynamic-screen` 保留屏幕空间聚类,适合船只这类实时高频图层;`none` 关闭聚类。稳定球面聚类的 cluster 圆点刚性落在成员 3D 质心投影上,不参与 2D 避让,避免缩放时被推离真实地理位置。cluster 圆点大小随包含对象数量增长,数量过多时按稳定地理顺序拆成多个较小圆点;数量默认只在 hover tooltip 中显示。这个过程只设置 `icon_cluster_*` 展示元数据和重建渲染 Points,不改变每个 marker 的真实经纬度。
|
||||
`Interactable` 的单点显示只由全局地图缩放决定:170% 及以下强制显示小圆点,超过 170% 显示原图标。cluster 现在由 `cluster.strategy` 决定:`stable-spherical` 使用离散 zoom band 和 3D 球面分桶,BGP、算力中心和 Earth interactable 在同一 band 内旋转或细微缩放时不会重新计算聚合拓扑;`dynamic-screen` 保留屏幕空间聚类;`none` 关闭聚类。船只显式关闭聚类与避让,保证增量更新时不重建聚类拓扑。稳定球面聚类的 cluster 圆点刚性落在成员 3D 质心投影上,不参与 2D 避让,避免缩放时被推离真实地理位置。cluster 圆点大小随包含对象数量增长,数量过多时按稳定地理顺序拆成多个较小圆点;数量默认只在 hover tooltip 中显示。这个过程只设置 `icon_cluster_*` 展示元数据和重建渲染 Points,不改变每个 marker 的真实经纬度。
|
||||
|
||||
接口细节、生命周期和接入示例见:
|
||||
|
||||
|
||||
@@ -149,6 +149,8 @@
|
||||
|
||||
## 海缆与登陆点
|
||||
|
||||
`cable-batches.js` 将海缆线与登陆点分别合批绘制。下表的 Sprite 材质和尺寸仍属于 `cables.js` 中的拾取、选择代理;每帧把颜色、透明度、缩放和可见性同步到样式纹理或实例属性。实际绘制使用 `LineSegments` 和实例化 billboard,沿用原纹理、色彩、层级与球体遮挡规则。
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 默认海缆颜色 | `CABLE_COLORS.default` | `0xffff44` | 无数据颜色时使用 |
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
| 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset = 0.32`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用;半径与基座球拉开以避免远距 z-fighting。 |
|
||||
| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充;半径必须高于海陆基座并与基座球保持足够间距。 |
|
||||
| 1 | 大气辉光和云图 | `earth.js` | 大气 / 云层球 | 不走普通对象选择路径 | 云图由“大气云图”图层开关控制。 |
|
||||
| 1 | 海缆 / 登陆点 | `cables.js` | 海缆线和登陆点都使用 `renderOrder = 1`;半径偏移都为 `0.2`;登陆点是专用 `THREE.Sprite` 黄色扁平球 | 海缆走海缆拾取路径;登陆点 `depthTest: false` 保持球体完整,并用相机到球心的球体遮挡判断避免背面穿透 | 登陆点和海缆同层贴地,避免地表设施层的凌空感。 |
|
||||
| 1 | 海缆 / 登陆点 | `cables.js`, `cable-batches.js` | 海缆全量线段合并为一个 `LineSegments`;登陆点全量使用实例化 billboard;`renderOrder = 1`、半径偏移 `0.2` 保持不变 | 原始 `Line` / `Sprite` 仅作为逐项拾取和选择状态对象,材质不再单独绘制;登陆点仍按球体遮挡判断背面可见性,批量材质 `depthTest: false` | 样式通过线缆样式纹理和登陆点实例属性同步;保留颜色、脉冲、尺寸、显隐与点击语义。 |
|
||||
| 1.2 | 真实地形 | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` 加地形位移 | 禁用 raycast | 地形压过高清材质;高清材质关闭时临时隐藏,重新开启后恢复原状态。 |
|
||||
| 2.05 | 经纬线 | `earth.js` | `CONFIG.earthRadius + 0.14` | 禁用 raycast | 低透明度显示在高清材质上。 |
|
||||
| 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48`;claim 线不再额外抬高 | `depthTest: true`,禁用 raycast | 线层使用独立 line geometry 与 `renderOrder` 控制,但半径与高清材质壳完全一致,避免转动地球时与高清贴图出现视差。 |
|
||||
@@ -37,6 +37,14 @@
|
||||
| 12+ | 卫星锁定 ring、halo、预测轨道 | `satellites.js` | `SATELLITE_CONFIG.overlayRenderOrder` 及偏移;预测轨道使用同一真实高度开关,并固定锁定时刻的地球姿态来绘制闭合惯性轨道;关闭真实高度时回到同层球面 | 卫星覆盖层路径 | 用于选中 / 锁定卫星强调。 |
|
||||
| 98-100 | 太阳 / 月亮 halo 和 sprite | `celestial.js` | 固定 renderOrder | 天体拾取禁用 | 前景天体 sprite。 |
|
||||
|
||||
## 全量绘制与更新约束
|
||||
|
||||
- 合批只改变 GPU 提交方式,不减少卫星、线段或登陆点数量,也不按相机半球裁剪数据。海缆每对相邻顶点都保留,拾取仍返回原始业务对象。
|
||||
- 卫星普通点与背景点保持两个 `Points` 绘制;呼吸动画由顶点着色器使用静态逐点参数和每帧统一时间计算。hover / locked 的隐藏标记仍由原有选择状态控制。
|
||||
- `satellite-position-worker.js` 使用与主线程相同的 Three.js / SGP4 版本,通过 `satellite-propagation.js` 共享轨道、显示高度和 fallback 计算;全量位置及初始轨迹以可转移数组交给主线程。主线程维持一个在途计算,不堆积过期帧;重载、清空和高度模式变更会终止旧 Worker。
|
||||
- Worker 启动失败或超过 `SATELLITE_CONFIG.workerStartupTimeoutMs` 时退回共享的同步计算,避免图层无限等待。计数、数据接口、原有更新周期和轨迹长度不变。
|
||||
- 验证时同时检查全量 draw range、逐项拾取、锁定覆盖层、轨迹、背面遮挡、开关及清空后的资源释放;比较帧耗时必须使用相同数据、相同视角和分辨率。
|
||||
|
||||
## 开关联动
|
||||
|
||||
| 开关 | 行为 |
|
||||
|
||||
@@ -126,6 +126,9 @@
|
||||
|
||||
### AISStream 实时船舶
|
||||
|
||||
在智能星球打开船只图层后,AISStream 与 BarentsWatch 的后续位置会自动更新。更新时会保留已选中的船只;短暂断线重连后会自动校准,无需反复关闭、开启图层。
|
||||
|
||||
|
||||
`AISStream 实时船舶` 是全球 AIS WebSocket 采集器。连接测试通过只说明 API Key 和 endpoint 格式可用;真正的全球船只数据来自后台 `aisstream_vessels` collector 长连接运行并写入 `ais_raw_observations`。
|
||||
|
||||
操作步骤:
|
||||
@@ -163,7 +166,11 @@ provider 和模型既可选预设也可直接输入自定义 id/name。常用字
|
||||
- Max Tokens、Anthropic Version:可保持默认
|
||||
- Timeout / Retry:超时和重试次数
|
||||
|
||||
Base URL 输入框尾端的插头图标会触发连接测试。测试通过会显示当前模型返回的简短回复。
|
||||
Base URL 输入框尾端的插头图标会触发轻量连接测试:检查代理和模型目录,并确认当前模型出现在目录中。通过后可到 Playground 发起实际生成,核对账号权限和模型回复;目录检查本身不执行生成调用。
|
||||
|
||||
点击右上角的刷新图标可更新“可选模型”。成功后目录会保存,重新打开页面仍可使用;有发布日期的目录按新到旧排列。刷新保留当前模型、密钥、地址和未保存的修改。点击一个可选模型后,再点“保存”才会改变实际使用的模型。刷新失败时会显示错误并保留上次目录。
|
||||
|
||||
刷新使用当前表单中的基础地址、协议和 API Key,因此可以先填写新配置再刷新,无需提前保存。需要凭证的供应商必须填写对应地域的 Key;Ollama 的空目录表示尚未安装模型。选择已知协议的模型时,协议适配会同步到表单;自定义 OpenAI 网关保留手动选择的协议。
|
||||
|
||||
### 工具
|
||||
|
||||
@@ -281,6 +288,10 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
|
||||
|
||||
支持查找海缆、登陆点、卫星、算力中心、BGP 事件、BGP 观测站。结果可快速定位并打开详情。
|
||||
|
||||
### 新闻直播源选择
|
||||
|
||||
打开媒体面板的直播页,点击当前频道可展开搜索菜单。搜索会检索完整频道库;列表每次加载 50 个频道,滚动到底部自动追加。底部固定显示已加载数量和搜索结果总数,加载失败时可在列表下方重试。默认新闻直播源为 Al Jazeera Mubasher(半岛电视台),使用 HLS 播放地址;其他频道仍取决于各自的播放服务是否可用。
|
||||
|
||||
### 位置候选采集
|
||||
|
||||
算力中心和 BGP 观测站详情卡支持自动采集坐标候选。点击对象后用"自动采集坐标候选"或"重新自动采集坐标"按钮,后端会从源坐标、开放组织注册 API 和在线地理编码中整理候选;常规来源没有候选时使用当前默认 AI Provider 做 LLM factcheck 兜底。BGP 观测站的已存储位置只用于补齐查询上下文,不会作为候选直接返回。
|
||||
@@ -297,6 +308,8 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
|
||||
|
||||
一键定位用于批量处理算力中心待定位队列。它会从列表顶部开始采用最高置信候选;仍没有事实依据的记录会保留在队列中。未开启 WebSearch 时,单个定位和一键定位会置灰,因为位置核验依赖事实查询。
|
||||
|
||||
在当前智能星球页面内,关闭候选面板、查看其他对象或切换浏览器标签页不会取消队列;返回候选列表后可继续查看进度和结果。已保存条目不会再次采集,全部完成后仍可通过图层旁的 ✓ 入口查看结果。刷新、关闭页面或跳转离开智能星球会中断尚未完成的队列,已经保存的坐标会保留。
|
||||
|
||||
### 设置
|
||||
|
||||
设置面板按分类组织:运行、显示、面板、动捕、快捷键、系统。里面包含旋转模式 / 巡航模式 / 动捕模式、巡航模块(BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼 / 识别动作白名单、快捷键启用与改键、地球默认大小、地形透明度、重置设置。
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## 背景
|
||||
|
||||
日常启动使用 `zsh ./planet.sh start --non-motion-agent`;新环境首次准备才需要 `init`。排查耗时时,应区分首次依赖下载、容器就绪和应用初始化,结合阶段日志时间判断。
|
||||
|
||||
当前启动流程在准备 AI Provider 镜像前验证后端实际数据库连接,端口映射缺失时保留数据卷重建一次。后端进程退出,或 Uvicorn 日志出现应用初始化失败、ASGI 加载失败、导入或语法错误时,会立即停止等待和重复启动;正常的慢启动仍保留原有等待预算。AI Provider 直接探测宿主机 `/health`,无需再等待 Docker 周期性健康检查首次运行。
|
||||
|
||||
`planet.sh` 管理所有服务的启动/停止/重启。原有实现存在以下问题:
|
||||
|
||||
1. AI Provider 每次都重新构建(即使代码未变)
|
||||
@@ -37,27 +41,9 @@ write_ai_provider_build_stamp() {
|
||||
}
|
||||
```
|
||||
|
||||
### fingerprint 计算提速
|
||||
### fingerprint 检查范围
|
||||
|
||||
原实现对整个 `aiprovider/` 打 tar 包再算 SHA,大目录下耗时可达数秒。改为 `find + stat`(只读文件元信息,不读内容):
|
||||
|
||||
```bash
|
||||
compute_ai_provider_build_fingerprint() {
|
||||
find aiprovider \
|
||||
-type f \
|
||||
! -path '*/__pycache__/*' \
|
||||
! -name '.env' \
|
||||
! -name '.env.*' \
|
||||
! -name '*.pyc' \
|
||||
! -name '*.pyo' \
|
||||
| LC_ALL=C sort \
|
||||
| xargs -r stat --format="%Y %s %n" 2>/dev/null
|
||||
sha256sum docker-compose.yml docker-compose.simple.yml 2>/dev/null
|
||||
python3 "$SCRIPT_DIR/scripts/compute_aiprovider_dependency_fingerprint.py" 2>/dev/null
|
||||
}
|
||||
```
|
||||
|
||||
速度提升约 10 倍(大量小文件场景),误报率相同(mtime+size 变化 ≡ 文件被修改)。
|
||||
当前指纹使用内容 SHA,覆盖 `aiprovider/`、Dockerfile、根依赖清单与锁文件,以及代理服务相关依赖信息;不会遍历前端资源或下载数据。`.env` 配置不参与镜像内容指纹。构建标记只用于判断是否需要构建,Dockerfile 中的指纹标签位于依赖安装之后,以保留前置依赖层缓存。
|
||||
|
||||
`.env` 和 `.env.*` 被排除在 fingerprint 外。它们属于运行期配置,不应该因为修改模型、密钥或 Base URL 触发镜像重建。
|
||||
|
||||
@@ -85,13 +71,15 @@ Dockerfile 也从全仓复制改为只复制 AI Provider 代码:
|
||||
```dockerfile
|
||||
COPY pyproject.toml uv.lock /app/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev
|
||||
uv sync --frozen --only-group aiprovider
|
||||
|
||||
COPY aiprovider /app/aiprovider
|
||||
```
|
||||
|
||||
`uv sync` 使用 BuildKit cache mount 后,首次构建仍可能受网络影响;后续构建会复用 `/root/.cache/uv`,依赖下载不再重复从零开始。
|
||||
|
||||
`aiprovider` 依赖组在根 `pyproject.toml` 定义,并由同一份 `uv.lock` 锁定,只安装 FastAPI、HTTP 客户端、配置读取和 ASGI 服务所需依赖。镜像不安装后端采集或 OpenCV / MediaPipe 动捕依赖。构建指纹标签放在依赖安装和代码复制之后,指纹改变不会单独使依赖层缓存失效。容器直接启动已安装的 `.venv/bin/python`,运行时不再执行 `uv sync`。修改代理服务的依赖时,应同步更新该依赖组和锁文件,并验证镜像导入及 `/health`。
|
||||
|
||||
### 运行期配置来源
|
||||
|
||||
`planet.sh` 启动 AI Provider 前会生成受当前用户保护的运行期 env-file,并把它传给 Compose 或手动 `docker run` fallback。默认路径位于 `${XDG_STATE_HOME:-$HOME/.local/state}/planet/aiprovider_runtime.env`。配置优先来自:
|
||||
@@ -119,7 +107,7 @@ fingerprint 一致时不执行 `docker compose build`,而是:
|
||||
docker start planet_aiprovider # 启动已存在的容器,几秒内完成
|
||||
```
|
||||
|
||||
`docker stop` 停容器,不删镜像;`cleanup_exit_containers` 删已退出容器,不删镜像。下次 `docker start` 会从现有镜像直接创建并启动容器。
|
||||
`docker stop` 停容器,不删镜像。`start` 和 `restart` 保留已停止的容器,不再扫描删除全机已退出容器;未变化的 AI Provider 可直接复用,数据库仍由 Compose 同步配置。只有需要更新镜像或容器配置时才按原有流程重建。
|
||||
|
||||
## 问题二:杀端口速度慢
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ docker buildx version
|
||||
|
||||
## 数据库初始化与连接检查
|
||||
|
||||
`init` 会先通过 Compose 同步 PostgreSQL / Redis 容器配置,包括已有容器的端口映射;仅执行 `docker start` 无法应用配置变化。Compose 同步失败时会保留具体错误,例如端口被占用,不会继续复用旧容器并报告成功。
|
||||
`init` 和 `start` 会先通过 Compose 同步 PostgreSQL / Redis 容器配置,包括已有容器的端口映射;仅执行 `docker start` 无法应用配置变化。Compose 同步失败时会保留具体错误,例如端口被占用,不会继续复用旧容器并报告成功。
|
||||
|
||||
容器内部的 `pg_isready` 只检查服务是否接受连接,不能证明宿主机上的后端使用正确地址和密码。容器健康后,`init` 通过 `scripts/check_database_connection.py` 读取与后端相同的有效 `DATABASE_URL`,检查本地 PostgreSQL 的实际发布端口并执行只读 `SELECT 1`;通过后才显示“数据库服务已就绪”并创建表和默认数据。
|
||||
容器内部的 `pg_isready` 只检查服务是否接受连接,不能证明宿主机上的后端使用正确地址和密码。容器健康后,`init` 和后端启动流程通过 `scripts/check_database_connection.py` 读取与后端相同的有效 `DATABASE_URL`,检查本地 PostgreSQL 的实际发布端口并执行只读 `SELECT 1`。启动流程在准备 AI Provider 镜像之前完成此检查;失败会立即停止。`init` 通过检查后才创建表和默认数据。
|
||||
|
||||
- 如果本地实际端口映射仍缺失或不匹配,脚本会保留数据卷,按 Compose 配置重建一次 PostgreSQL 并重新检查;再次失败就停止。
|
||||
- 认证、库名或网络错误会在建表前停止,诊断只显示目标主机、端口和库名,不输出密码、完整连接串或驱动异常原文。
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
进入 `/admin` 仪表盘后,建议按这个顺序熟悉控制台:
|
||||
|
||||
1. `/collection-management?section=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
|
||||
2. `/ai?section=integrations`:填一个 LLM provider(例如 `minimax` / `openai`)、模型名、Base URL、API Key,点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选
|
||||
2. `/ai?section=integrations`:填一个 LLM provider(例如 `minimax` / `openai`)、模型名、Base URL、API Key,点 Base URL 末端的插头检查模型目录;实际生成在 Playground 验证。右上角刷新图标会更新并保存可选模型目录,同时保留当前表单;选中新模型后点“保存”生效。WebSearch / OCR 工具可选
|
||||
3. `/earth-content?section=brand`:在“品牌标识”里维护智能星球的 Logo 和标题图;对应地址字段内的“上传”按钮支持选择文件,也支持把图片直接拖到字段上,保存后会应用到智能星球 HUD
|
||||
4. `/datasources` 或 `/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 内置源`,不勾选时点“触发全部”,勾选后主按钮会变成“触发已选 N”;右上角队列按钮可查看进度。AISStream / WebSocket 长连接看 `/datasources -> 实时源` 的健康状态和计数
|
||||
5. `/alerts/system`:看系统告警是否正常
|
||||
@@ -50,6 +50,9 @@
|
||||
- 地球正常显示,右侧图层面板可以打开/关闭
|
||||
- 搜索可以查找海缆、卫星、算力中心、BGP 事件
|
||||
- 算力中心和 BGP 观测站详情卡可以自动采集坐标候选,并能在智能星球上预览
|
||||
- 一键定位期间关闭候选面板或切换浏览器标签页,再返回可查看进度;刷新或离开智能星球页面会中断未完成队列
|
||||
- 船只图层持续接收 AISStream / BarentsWatch 位置,断线重连后自动校准
|
||||
- 直播菜单可搜索完整频道库,滚到底部继续加载;默认频道为半岛电视台
|
||||
- 鼠标拖动、滚轮缩放、缩放百分比提示工作正常
|
||||
- 设置面板的旋转 / 巡航 / 动捕模式可以切换;动捕设置可以选择输入源和允许识别的动作;视图设置里可以切换悬停提示,卫星相关设置里可以打开或关闭真实高度分层和轨迹显示
|
||||
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.74.3`
|
||||
- `dev` 当前开发分支历史推导到:`0.74.5`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.74.5` | improvement | `dev` | `v0.74.5` | 恢复模型预设持久化,统一官方目录刷新与连通性检查,修复 M3 思考模式和 OpenCode 协议路由 |
|
||||
| `0.74.4` | improvement | `dev` | `v0.74.4` | Earth 全量渲染与船舶增量更新优化,直播目录搜索和分页,定位队列状态恢复、模型目录保存及启动提速 |
|
||||
| `0.74.3` | improvement | `dev` | `v0.74.3` | Ubuntu / WSL 初始化自动准备 Docker 及用户权限,修正启动诊断,并在建表前核对数据库端口、实际连接和认证 |
|
||||
| `0.74.2` | bugfix | `dev` | `pending` | 收敛 agent harness 到根规则和 Codex skills,删除旧 Claude command 重复入口,并强化视觉证据路径解析与 OCR fallback 规则 |
|
||||
| `0.74.1` | improvement | `dev` | `pending` | 将品牌标识上传收敛到 Logo/标题图字段内,新增字段级拖拽反馈和 Tactile UI primary 上传按钮,并同步中英文使用文档 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.74.3",
|
||||
"version": "0.74.5",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1100,7 +1100,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-select,
|
||||
.earth-mobile-settings-slider {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1215,14 +1214,6 @@
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-select {
|
||||
border: 1px solid rgba(201, 225, 247, 0.14);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--hud-text);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.earth-mobile-tv-player {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
|
||||
@@ -669,14 +669,21 @@
|
||||
.info-card-compute-candidate-preview.is-loading::before,
|
||||
.info-card-unresolved-item.is-locating .info-card-unresolved-index::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
width: calc(10px * var(--hud-scale));
|
||||
height: calc(10px * var(--hud-scale));
|
||||
vertical-align: middle;
|
||||
border: 1.5px solid rgba(201, 220, 255, 0.35);
|
||||
border-top-color: #c9dcff;
|
||||
border-radius: 999px;
|
||||
animation: info-card-location-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.info-card-compute-candidate-preview.is-loading::before {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
.info-card-unresolved-item.is-locating .info-card-unresolved-index {
|
||||
color: transparent;
|
||||
}
|
||||
@@ -848,9 +855,3 @@
|
||||
.info-card-unresolved-adopt:hover {
|
||||
background: rgba(255, 171, 81, 0.16);
|
||||
}
|
||||
|
||||
.info-card-unresolved-empty {
|
||||
padding: calc(10px * var(--hud-scale)) 0;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.74rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
@@ -109,10 +109,196 @@
|
||||
font-size: calc(0.84rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-panel-select option,
|
||||
.tv-panel-select optgroup {
|
||||
background: #0a1422;
|
||||
color: #eef5fc;
|
||||
.tv-source-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--hud-gap-xs);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
height: calc(36px * var(--hud-scale));
|
||||
padding-block: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tv-source-trigger__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tv-source-trigger > .material-symbols-rounded {
|
||||
flex: 0 0 auto;
|
||||
font-size: calc(16px * var(--hud-scale));
|
||||
color: var(--hud-text-soft);
|
||||
}
|
||||
|
||||
.tv-source-trigger[aria-expanded="true"] {
|
||||
border-color: var(--hud-border-hover);
|
||||
background: rgba(120, 180, 255, 0.12);
|
||||
}
|
||||
|
||||
.tv-source-trigger:focus-visible,
|
||||
.tv-source-menu__more:focus-visible {
|
||||
outline: 2px solid var(--hud-accent-strong);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tv-source-menu {
|
||||
position: fixed;
|
||||
inset: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
color: var(--hud-text);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.tv-source-menu:popover-open {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tv-source-menu__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--hud-gap-xs);
|
||||
flex: 0 0 auto;
|
||||
padding: calc(10px * var(--hud-scale));
|
||||
border-bottom: 1px solid var(--hud-line);
|
||||
}
|
||||
|
||||
.tv-source-menu__search > .material-symbols-rounded {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(18px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-source-menu__search input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--hud-text);
|
||||
font: inherit;
|
||||
font-size: calc(0.78rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-source-menu__search:focus-within {
|
||||
box-shadow: inset 0 -1px 0 var(--hud-accent-strong);
|
||||
}
|
||||
|
||||
.tv-source-menu__search input::placeholder {
|
||||
color: var(--hud-text-soft);
|
||||
}
|
||||
|
||||
.tv-source-menu__body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tv-source-menu__list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.tv-source-menu__option {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--hud-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tv-source-menu__option.is-active {
|
||||
background: rgba(120, 180, 255, 0.1);
|
||||
}
|
||||
|
||||
.tv-source-menu__option[aria-selected="true"] {
|
||||
background: rgba(120, 180, 255, 0.15);
|
||||
}
|
||||
|
||||
.tv-source-menu__option > .material-symbols-rounded {
|
||||
flex: 0 0 auto;
|
||||
font-size: calc(14px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-source-menu__check {
|
||||
color: var(--hud-accent-strong);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.tv-source-menu__option[aria-selected="true"] .tv-source-menu__check {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.tv-source-menu__default,
|
||||
.tv-source-menu__count,
|
||||
.tv-source-menu__more {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-source-menu__default {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tv-source-menu__warning {
|
||||
color: #ffd166;
|
||||
}
|
||||
|
||||
.tv-source-menu__count,
|
||||
.tv-source-menu__empty,
|
||||
.tv-source-menu__more {
|
||||
flex: 0 0 auto;
|
||||
padding: calc(8px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-source-menu__count {
|
||||
border-top: 1px solid var(--hud-line);
|
||||
}
|
||||
|
||||
.tv-source-menu__empty {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.78rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.tv-source-menu__more {
|
||||
border: 0;
|
||||
background: rgba(120, 180, 255, 0.06);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tv-source-menu [hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-mobile-page--tv > .tv-source-trigger {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .tv-source-menu__search input,
|
||||
.layout-mode-mobile .tv-source-menu__option .legend-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.layout-mode-mobile .tv-source-menu__search,
|
||||
.layout-mode-mobile .tv-source-menu__option {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.tv-panel-meta-wrap {
|
||||
|
||||
@@ -511,7 +511,7 @@
|
||||
<span class="hud-panel-title hud-panel__title tv-panel-header-title">Live 新闻</span>
|
||||
</div>
|
||||
<div id="tv-header-controls-live" class="tv-panel-header-controls tv-panel-header-controls--live">
|
||||
<select id="tv-source-select" class="tv-panel-select" aria-label="选择新闻直播源"></select>
|
||||
<button id="tv-source-select" class="tv-panel-select tv-source-trigger" type="button" aria-label="选择新闻直播源" aria-haspopup="dialog" aria-expanded="false"></button>
|
||||
<div class="tv-panel-toolbar-actions">
|
||||
<button id="tv-refresh" class="hud-panel__action hud-panel__action--refresh" type="button" title="刷新直播源" aria-label="刷新直播源">
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
@@ -815,7 +815,7 @@
|
||||
<span class="earth-mobile-page-kicker">TV</span>
|
||||
<span class="earth-mobile-page-summary">移动端新闻直播和频道切换</span>
|
||||
</div>
|
||||
<select id="mobile-tv-source-select" class="earth-mobile-tv-select" aria-label="选择移动端新闻直播源"></select>
|
||||
<button id="mobile-tv-source-select" class="tv-panel-select tv-source-trigger" type="button" aria-label="选择移动端新闻直播源" aria-haspopup="dialog" aria-expanded="false"></button>
|
||||
<div class="earth-mobile-tv-player">
|
||||
<div id="mobile-tv-empty-state" class="earth-mobile-tv-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
|
||||
<iframe
|
||||
|
||||
176
frontend/public/earth/js/cable-batches.js
Normal file
176
frontend/public/earth/js/cable-batches.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
const CABLE_STYLE_TEXTURE_MAX_WIDTH = 1024;
|
||||
|
||||
// Keep the original Line/Sprite objects for picking and selection. Only their
|
||||
// drawing is replaced: every segment and landing point remains in the batch.
|
||||
function ownBatch(object, disposeExtra = () => {}) {
|
||||
object.frustumCulled = false;
|
||||
object.raycast = () => {};
|
||||
return {
|
||||
object,
|
||||
dispose() {
|
||||
object.parent?.remove(object);
|
||||
object.geometry.dispose();
|
||||
object.material.dispose();
|
||||
disposeExtra();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createCableLineBatch(lines) {
|
||||
if (!lines.length) return null;
|
||||
const vertexCount = lines.reduce((count, line) =>
|
||||
count + Math.max(0, line.geometry.attributes.position.count - 1) * 2, 0);
|
||||
const positions = new Float32Array(vertexCount * 3);
|
||||
const styleIndices = new Float32Array(vertexCount);
|
||||
const width = Math.min(lines.length, CABLE_STYLE_TEXTURE_MAX_WIDTH);
|
||||
const height = Math.ceil(lines.length / width);
|
||||
const styles = new Float32Array(width * height * 4);
|
||||
const styleTexture = new THREE.DataTexture(styles, width, height, THREE.RGBAFormat, THREE.FloatType);
|
||||
styleTexture.needsUpdate = true;
|
||||
let cursor = 0;
|
||||
lines.forEach((line, index) => {
|
||||
const source = line.geometry.attributes.position;
|
||||
for (let segment = 0; segment < source.count - 1; segment += 1) {
|
||||
for (const endpoint of [segment, segment + 1]) {
|
||||
positions[cursor * 3] = source.getX(endpoint);
|
||||
positions[cursor * 3 + 1] = source.getY(endpoint);
|
||||
positions[cursor * 3 + 2] = source.getZ(endpoint);
|
||||
styleIndices[cursor] = index;
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
line.material.visible = false;
|
||||
line.updateMatrix();
|
||||
line.matrixAutoUpdate = false;
|
||||
});
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("styleIndex", new THREE.BufferAttribute(styleIndices, 1));
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
styles: { value: styleTexture },
|
||||
styleSize: { value: new THREE.Vector2(width, height) },
|
||||
},
|
||||
transparent: true,
|
||||
depthTest: true,
|
||||
depthWrite: true,
|
||||
vertexShader: `
|
||||
uniform sampler2D styles;
|
||||
uniform vec2 styleSize;
|
||||
attribute float styleIndex;
|
||||
varying vec4 vStyle;
|
||||
void main() {
|
||||
vec2 uv = (vec2(mod(styleIndex, styleSize.x), floor(styleIndex / styleSize.x)) + 0.5) / styleSize;
|
||||
vStyle = texture2D(styles, uv);
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
varying vec4 vStyle;
|
||||
void main() {
|
||||
if (vStyle.a <= 0.0) discard;
|
||||
gl_FragColor = vStyle;
|
||||
}
|
||||
`,
|
||||
});
|
||||
material.linewidth = lines[0].material.linewidth;
|
||||
const object = new THREE.LineSegments(geometry, material);
|
||||
object.name = "cable-line-batch";
|
||||
object.renderOrder = lines[0].renderOrder;
|
||||
object.onBeforeRender = () => {
|
||||
let changed = false;
|
||||
lines.forEach((line, index) => {
|
||||
const { color, opacity } = line.material;
|
||||
const offset = index * 4;
|
||||
const alpha = line.visible ? opacity : 0;
|
||||
if (styles[offset] !== Math.fround(color.r) || styles[offset + 1] !== Math.fround(color.g)
|
||||
|| styles[offset + 2] !== Math.fround(color.b) || styles[offset + 3] !== Math.fround(alpha)) {
|
||||
styles[offset] = color.r;
|
||||
styles[offset + 1] = color.g;
|
||||
styles[offset + 2] = color.b;
|
||||
styles[offset + 3] = alpha;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) styleTexture.needsUpdate = true;
|
||||
};
|
||||
return ownBatch(object, () => styleTexture.dispose());
|
||||
}
|
||||
|
||||
export function createLandingPointBatch(markers, texture) {
|
||||
if (!markers.length) return null;
|
||||
const geometry = new THREE.InstancedBufferGeometry();
|
||||
const quad = new THREE.PlaneGeometry(1, 1);
|
||||
geometry.index = quad.index;
|
||||
geometry.attributes.position = quad.attributes.position;
|
||||
geometry.attributes.uv = quad.attributes.uv;
|
||||
const centers = new Float32Array(markers.length * 3);
|
||||
const sizes = new THREE.InstancedBufferAttribute(new Float32Array(markers.length * 2), 2)
|
||||
.setUsage(THREE.DynamicDrawUsage);
|
||||
const styles = new THREE.InstancedBufferAttribute(new Float32Array(markers.length * 4), 4)
|
||||
.setUsage(THREE.DynamicDrawUsage);
|
||||
markers.forEach((marker, index) => {
|
||||
marker.position.toArray(centers, index * 3);
|
||||
marker.material.visible = false;
|
||||
});
|
||||
geometry.setAttribute("center", new THREE.InstancedBufferAttribute(centers, 3));
|
||||
geometry.setAttribute("size", sizes);
|
||||
geometry.setAttribute("style", styles);
|
||||
geometry.instanceCount = markers.length;
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms: { map: { value: texture } },
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
vertexShader: `
|
||||
attribute vec3 center;
|
||||
attribute vec2 size;
|
||||
attribute vec4 style;
|
||||
varying vec2 vUv;
|
||||
varying vec4 vStyle;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vStyle = style;
|
||||
vec2 scale = vec2(length(modelMatrix[0].xyz), length(modelMatrix[1].xyz));
|
||||
vec4 viewPosition = modelViewMatrix * vec4(center, 1.0);
|
||||
viewPosition.xy += position.xy * size * scale;
|
||||
gl_Position = style.a > 0.0 ? projectionMatrix * viewPosition : vec4(2.0, 2.0, 2.0, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform sampler2D map;
|
||||
varying vec2 vUv;
|
||||
varying vec4 vStyle;
|
||||
void main() {
|
||||
vec4 color = texture2D(map, vUv) * vStyle;
|
||||
if (color.a < 0.01) discard;
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`,
|
||||
});
|
||||
const object = new THREE.Mesh(geometry, material);
|
||||
object.name = "landing-point-batch";
|
||||
object.renderOrder = markers[0].renderOrder;
|
||||
object.onBeforeRender = () => {
|
||||
let sizeChanged = false;
|
||||
let styleChanged = false;
|
||||
markers.forEach((marker, index) => {
|
||||
const { color, opacity } = marker.material;
|
||||
const alpha = marker.visible ? opacity : 0;
|
||||
if (sizes.getX(index) !== Math.fround(marker.scale.x) || sizes.getY(index) !== Math.fround(marker.scale.y)) {
|
||||
sizes.setXY(index, marker.scale.x, marker.scale.y);
|
||||
sizeChanged = true;
|
||||
}
|
||||
if (styles.getX(index) !== Math.fround(color.r) || styles.getY(index) !== Math.fround(color.g)
|
||||
|| styles.getZ(index) !== Math.fround(color.b) || styles.getW(index) !== Math.fround(alpha)) {
|
||||
styles.setXYZW(index, color.r, color.g, color.b, alpha);
|
||||
styleChanged = true;
|
||||
}
|
||||
});
|
||||
if (sizeChanged) sizes.needsUpdate = true;
|
||||
if (styleChanged) styles.needsUpdate = true;
|
||||
};
|
||||
return ownBatch(object);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// cables.js - Cable loading and rendering module
|
||||
|
||||
import * as THREE from "three";
|
||||
import { createCableLineBatch, createLandingPointBatch } from "./cable-batches.js";
|
||||
|
||||
import {
|
||||
CONFIG,
|
||||
@@ -23,6 +24,8 @@ let landingPointSourceFeatureCount = 0;
|
||||
let cableIdMap = new Map();
|
||||
let cableStates = new Map();
|
||||
let cablesVisible = true;
|
||||
let cableLineBatch = null;
|
||||
let landingPointBatch = null;
|
||||
const _lpEarthWorldPos = new THREE.Vector3();
|
||||
const _lpWorldPos = new THREE.Vector3();
|
||||
const _lpCameraRel = new THREE.Vector3();
|
||||
@@ -303,6 +306,8 @@ function calculateGreatCirclePoints(
|
||||
}
|
||||
|
||||
export function clearCableLines(earthObj = null) {
|
||||
cableLineBatch?.dispose();
|
||||
cableLineBatch = null;
|
||||
cableLines.forEach((line) => disposeObject(line, earthObj));
|
||||
cableLines = [];
|
||||
cableSourceFeatureCount = 0;
|
||||
@@ -311,6 +316,8 @@ export function clearCableLines(earthObj = null) {
|
||||
}
|
||||
|
||||
export function clearLandingPoints(earthObj = null) {
|
||||
landingPointBatch?.dispose();
|
||||
landingPointBatch = null;
|
||||
landingPoints.forEach((point) => disposeObject(point, earthObj));
|
||||
landingPoints = [];
|
||||
landingPointSourceFeatureCount = 0;
|
||||
@@ -404,6 +411,11 @@ export async function loadGeoJSONFromPath(scene, earthObj, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
cableLineBatch = createCableLineBatch(cableLines);
|
||||
if (cableLineBatch) {
|
||||
cableLineBatch.object.visible = cablesVisible;
|
||||
earthObj.add(cableLineBatch.object);
|
||||
}
|
||||
const cableCount = data.features.length;
|
||||
const inServiceCount = data.features.filter(
|
||||
(feature) =>
|
||||
@@ -508,6 +520,11 @@ export async function loadLandingPoints(scene, earthObj, options = {}) {
|
||||
landingPoints.push(marker);
|
||||
}
|
||||
|
||||
landingPointBatch = createLandingPointBatch(landingPoints, markerTexture);
|
||||
if (landingPointBatch) {
|
||||
landingPointBatch.object.visible = cablesVisible;
|
||||
earthObj.add(landingPointBatch.object);
|
||||
}
|
||||
const validCount = landingPoints.length;
|
||||
|
||||
setEarthStatValue("landing-point-count", `${validCount}个`);
|
||||
@@ -711,6 +728,8 @@ export function resetLandingPointVisualState(camera = null) {
|
||||
|
||||
export function toggleCables(show) {
|
||||
cablesVisible = show;
|
||||
if (cableLineBatch) cableLineBatch.object.visible = show;
|
||||
if (landingPointBatch) landingPointBatch.object.visible = show;
|
||||
cableLines.forEach((cable) => {
|
||||
cable.visible = cablesVisible;
|
||||
});
|
||||
|
||||
@@ -389,6 +389,7 @@ export const CABLE_STATE = {
|
||||
};
|
||||
|
||||
export const SATELLITE_CONFIG = {
|
||||
workerStartupTimeoutMs: 10000,
|
||||
maxCount: -1,
|
||||
initialLoadCount: null,
|
||||
hydrateFullAfterInitialLoad: false,
|
||||
|
||||
@@ -16,6 +16,10 @@ function localizedParam(value, locale) {
|
||||
}
|
||||
|
||||
const EARTH_MESSAGE_TEMPLATES = {
|
||||
"tv.channelResults": {
|
||||
zh: ({ count, total }) => `已加载 ${count} / ${total} 个频道`,
|
||||
en: ({ count, total }) => `${count} of ${total} channels loaded`,
|
||||
},
|
||||
"loading.default": {
|
||||
zh: "正在加载...",
|
||||
en: "Loading...",
|
||||
@@ -418,6 +422,11 @@ const EXACT_TRANSLATIONS = {
|
||||
"关闭地球信息": "Close Earth info",
|
||||
"Live 新闻": "Live News",
|
||||
"选择新闻直播源": "Select live news source",
|
||||
"频道列表": "Channel list",
|
||||
"搜索频道、地区…": "Search channels, regions…",
|
||||
"无匹配频道": "No matching channels",
|
||||
"正在加载频道…": "Loading channels…",
|
||||
"加载频道失败,点击重试": "Could not load channels. Click to retry",
|
||||
"刷新直播源": "Refresh live source",
|
||||
"访问官网": "Visit website",
|
||||
"折叠新闻直播内容": "Collapse live news details",
|
||||
@@ -619,7 +628,7 @@ const EXACT_TRANSLATIONS = {
|
||||
"运行中": "Running",
|
||||
"当前无活跃事件": "No active events",
|
||||
"AISStream 未连接": "AISStream Disconnected",
|
||||
"AISStream 实时已连接 · 等待首批更新": "AISStream live connected · waiting for first updates",
|
||||
"船舶实时通道已连接 · 等待首批更新": "Vessel live updates connected · waiting for first updates",
|
||||
"AISStream 正在重连": "AISStream reconnecting",
|
||||
"AISStream 正在连接": "AISStream connecting",
|
||||
"暂无可播放直播源": "No playable live source",
|
||||
@@ -1213,8 +1222,8 @@ const PATTERN_TRANSLATIONS = [
|
||||
[/^已切换到(.+)$/, (match, locale) => `Switched to ${translateText(match[1], locale)}`],
|
||||
[/^最近同步 (.+)$/, "Last synced $1"],
|
||||
[/^缩放 (\d+)%$/, "Zoom $1%"],
|
||||
[/^AISStream 实时已连接 · (\d+) 次更新$/, "AISStream live connected · $1 updates"],
|
||||
[/^AISStream 实时已连接 · (\d+) 次更新 · (.+)$/, "AISStream live connected · $1 updates · $2"],
|
||||
[/^船舶实时通道已连接 · (\d+) 次更新$/, "Vessel live updates connected · $1 updates"],
|
||||
[/^船舶实时通道已连接 · (\d+) 次更新 · (.+)$/, "Vessel live updates connected · $1 updates · $2"],
|
||||
[/^AISStream 后台已连接$/, "AISStream backend connected"],
|
||||
[/^AISStream 后台已连接 · 最近 (.+)$/, "AISStream backend connected · last seen $1"],
|
||||
[/^(\d+) 秒前$/, "$1s ago"],
|
||||
@@ -1497,8 +1506,9 @@ function translateElementTree(root) {
|
||||
}
|
||||
|
||||
function syncDocumentLocale() {
|
||||
document.documentElement.lang = activeLocale;
|
||||
document.title = translateText("智能星球计划 - 现实层宇宙全息感知", activeLocale);
|
||||
if (document.documentElement.lang !== activeLocale) document.documentElement.lang = activeLocale;
|
||||
const title = translateText("智能星球计划 - 现实层宇宙全息感知", activeLocale);
|
||||
if (document.title !== title) document.title = title;
|
||||
}
|
||||
|
||||
function persistLocale(locale) {
|
||||
@@ -1515,13 +1525,17 @@ export function applyEarthI18n(root = document.body) {
|
||||
if (translating) return;
|
||||
translating = true;
|
||||
try {
|
||||
syncDocumentLocale();
|
||||
if (root === document || root === document.body || root instanceof Document) {
|
||||
syncDocumentLocale();
|
||||
translateElementTree(document.body);
|
||||
syncEarthLocaleControls();
|
||||
} else if (root instanceof Element) {
|
||||
translateElementTree(root);
|
||||
if (root.matches('[data-earth-locale], [data-earth-locale-switch]')
|
||||
|| root.querySelector('[data-earth-locale], [data-earth-locale-switch]')) {
|
||||
syncEarthLocaleControls();
|
||||
}
|
||||
}
|
||||
syncEarthLocaleControls();
|
||||
} finally {
|
||||
translating = false;
|
||||
}
|
||||
@@ -1532,22 +1546,38 @@ function observeMutations() {
|
||||
observer = new MutationObserver((mutations) => {
|
||||
if (translating) return;
|
||||
const roots = new Set();
|
||||
let attributeChanged = false;
|
||||
const textNodes = new Set();
|
||||
const attributeTargets = new Set();
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === "childList") {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node instanceof Element) roots.add(node);
|
||||
else if (node.parentElement) roots.add(node.parentElement);
|
||||
else if (node instanceof Text) textNodes.add(node);
|
||||
});
|
||||
} else if (mutation.type === "characterData" && mutation.target.parentElement) {
|
||||
roots.add(mutation.target.parentElement);
|
||||
textNodes.add(mutation.target);
|
||||
} else if (mutation.type === "attributes" && mutation.target instanceof Element) {
|
||||
roots.add(mutation.target);
|
||||
attributeChanged = true;
|
||||
attributeTargets.add(mutation.target);
|
||||
}
|
||||
});
|
||||
if (roots.size === 0 && !attributeChanged) return;
|
||||
roots.forEach((root) => applyEarthI18n(root));
|
||||
// A ticker/date/title change should translate that value, not rescan its
|
||||
// whole panel and synchronize every language control in the document.
|
||||
const hasQueuedAncestor = (node) => {
|
||||
for (let parent = node.parentElement; parent; parent = parent.parentElement) {
|
||||
if (roots.has(parent)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
roots.forEach((root) => {
|
||||
if (root.isConnected && !hasQueuedAncestor(root)) applyEarthI18n(root);
|
||||
});
|
||||
textNodes.forEach((node) => {
|
||||
if (node.isConnected && !hasQueuedAncestor(node)) translateTextNode(node);
|
||||
});
|
||||
attributeTargets.forEach((element) => {
|
||||
if (element.isConnected && !roots.has(element) && !hasQueuedAncestor(element)
|
||||
&& !element.closest(SKIP_SELECTOR)) translateAttributes(element);
|
||||
});
|
||||
});
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
|
||||
@@ -64,7 +64,7 @@ function setLocationCollectState(contextOrKey, patch = {}) {
|
||||
: getLocationCollectCacheKey(contextOrKey);
|
||||
if (!key) return null;
|
||||
if (typeof contextOrKey !== 'string') {
|
||||
locationCollectContextCache.set(key, contextOrKey);
|
||||
rememberLocationCollectContext(contextOrKey);
|
||||
}
|
||||
if (Array.isArray(patch.candidates)) {
|
||||
locationCollectCandidatesByKey.set(key, patch.candidates);
|
||||
@@ -132,7 +132,18 @@ function updateComputeCenterLocationCapabilityDom() {
|
||||
button.disabled = blocked || button.classList.contains('is-loading');
|
||||
button.classList.toggle('is-websearch-blocked', blocked);
|
||||
button.title = blocked ? title : button.dataset.readyTitle || button.title || '';
|
||||
button.setAttribute('aria-disabled', blocked ? 'true' : 'false');
|
||||
button.setAttribute('aria-disabled', String(button.disabled));
|
||||
});
|
||||
const hasActiveCollection = Array.from(locationCollectStateCache.entries())
|
||||
.some(([key, state]) => key.startsWith('compute_center:') && state.loading);
|
||||
document.querySelectorAll('[data-unresolved-adopt-all], [data-unresolved-item] button')
|
||||
.forEach((button) => {
|
||||
const root = button.closest('[data-unresolved-item]');
|
||||
const busy = computeCenterUnresolvedBatchState.running || (root
|
||||
? getLocationCollectState(root.dataset.collectCacheKey)?.loading === true
|
||||
: hasActiveCollection);
|
||||
button.disabled = busy || (blocked && button.matches('[data-requires-web-search]'));
|
||||
button.setAttribute('aria-disabled', String(button.disabled));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -164,9 +175,14 @@ function setUnresolvedBatchState(patch = {}) {
|
||||
...patch,
|
||||
};
|
||||
updateUnresolvedBatchDom();
|
||||
window.dispatchEvent(new CustomEvent('earth:compute-center-location-batch-change'));
|
||||
return computeCenterUnresolvedBatchState;
|
||||
}
|
||||
|
||||
export function getComputeCenterLocationBatchState() {
|
||||
return { ...computeCenterUnresolvedBatchState };
|
||||
}
|
||||
|
||||
function getCandidateForButton(button) {
|
||||
if (!(button instanceof HTMLElement)) return null;
|
||||
const root = button.closest('[data-collect-cache-key]');
|
||||
@@ -182,6 +198,10 @@ function updateLocationCollectDomFromState(key) {
|
||||
if (!key) return;
|
||||
const state = getLocationCollectState(key);
|
||||
document.querySelectorAll(`[data-collect-cache-key="${escapeCssIdentifier(key)}"]`).forEach((root) => {
|
||||
if (state?.savedCandidate && root.matches('[data-unresolved-item]')) {
|
||||
removeResolvedUnresolvedItem(root.closest('.info-card-unresolved-list').parentElement, root);
|
||||
return;
|
||||
}
|
||||
hydrateLocationCollectRoot(root, state);
|
||||
ensureCandidateActionBindings(root, locationCollectContextCache.get(key));
|
||||
});
|
||||
@@ -592,7 +612,7 @@ function hydrateLocationCollectRoot(root, state) {
|
||||
function rememberLocationCollectContext(context) {
|
||||
const key = getLocationCollectCacheKey(context);
|
||||
if (!key) return '';
|
||||
locationCollectContextCache.set(key, context);
|
||||
locationCollectContextCache.set(key, { ...locationCollectContextCache.get(key), ...context });
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -613,7 +633,6 @@ function getLocationCollectContextForRoot(root, fallbackContext) {
|
||||
...parsed,
|
||||
entityType: 'compute_center',
|
||||
entityId: parsed.sourceId,
|
||||
isUnresolved: true,
|
||||
save: async (candidate) => {
|
||||
const mod = await import('./compute-centers.js');
|
||||
return mod.saveComputeCenterLocation(parsed.sourceId, candidate, parsed);
|
||||
@@ -681,6 +700,8 @@ function ensureCandidateActionBindings(rootOrChild, context) {
|
||||
loading: false,
|
||||
statusText: infoText('坐标已保存'),
|
||||
candidates: [],
|
||||
savedCandidate: candidate,
|
||||
saveResult,
|
||||
});
|
||||
if (statusEl) statusEl.textContent = infoText('坐标已保存');
|
||||
window.dispatchEvent(
|
||||
@@ -694,15 +715,6 @@ function ensureCandidateActionBindings(rootOrChild, context) {
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (actionContext.entityType === 'compute_center' && actionContext.isUnresolved === true) {
|
||||
const itemRoot = root.closest('[data-unresolved-item]');
|
||||
if (itemRoot) {
|
||||
removeResolvedUnresolvedItem(
|
||||
itemRoot.closest('#info-card-content') || document,
|
||||
itemRoot,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('save compute-center location failed', error);
|
||||
if (statusEl) statusEl.textContent = infoText(`保存失败:${error?.message || error}`);
|
||||
@@ -834,19 +846,11 @@ function getUnresolvedComputeCenterContext(item) {
|
||||
}
|
||||
|
||||
function renderComputeCenterUnresolvedContent(content, data) {
|
||||
const items = Array.isArray(data?.items) ? data.items : [];
|
||||
const items = (Array.isArray(data?.items) ? data.items : [])
|
||||
.filter((item) => !getLocationCollectState(getUnresolvedComputeCenterContext(item))?.savedCandidate);
|
||||
ensureComputeCenterLocationCapability();
|
||||
const blocked = isComputeCenterLocationBlocked();
|
||||
const disabledTitle = getWebSearchDisabledTitle();
|
||||
if (!items.length) {
|
||||
content.innerHTML = `
|
||||
<div class="info-card-unresolved-empty">
|
||||
${escapeInfoCardHtml(infoText('当前没有待定位算力中心'))}
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = items
|
||||
.map((item, index) => {
|
||||
const context = getUnresolvedComputeCenterContext(item);
|
||||
@@ -883,9 +887,12 @@ function renderComputeCenterUnresolvedContent(content, data) {
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="info-card-unresolved-summary">
|
||||
<span data-unresolved-summary-text>${escapeInfoCardHtml(infoText(`${items.length} 个算力中心没有可信坐标`))}</span>
|
||||
<span data-unresolved-summary-text>${escapeInfoCardHtml(infoText(items.length
|
||||
? `${items.length} 个算力中心没有可信坐标`
|
||||
: '当前没有待定位算力中心'))}</span>
|
||||
<button type="button" class="info-card-compute-candidate-preview info-card-unresolved-adopt"
|
||||
data-unresolved-adopt-all
|
||||
${items.length ? '' : 'hidden'}
|
||||
data-requires-web-search="true"
|
||||
data-ready-title="${escapeInfoCardHtml(infoText('一键定位并采用最高置信候选'))}"
|
||||
title="${escapeInfoCardHtml(blocked ? disabledTitle : infoText('一键定位并采用最高置信候选'))}"
|
||||
@@ -904,6 +911,7 @@ function renderComputeCenterUnresolvedContent(content, data) {
|
||||
}
|
||||
|
||||
function updateUnresolvedSummary(content) {
|
||||
if (!content?.isConnected || !content.querySelector('[data-unresolved-summary-text]')) return;
|
||||
const remainingCount = content.querySelectorAll('[data-unresolved-item]').length;
|
||||
const summaryText = content.querySelector('[data-unresolved-summary-text]');
|
||||
if (summaryText) {
|
||||
@@ -969,7 +977,6 @@ async function saveBestUnresolvedComputeCenterCandidate(context, progressLabel =
|
||||
setLocationCollectState(context, {
|
||||
loading: true,
|
||||
statusText: infoText(progressLabel || '正在一键定位并采用最高置信候选...'),
|
||||
candidates: [],
|
||||
});
|
||||
const { mod, result, candidates } = await collectUnresolvedComputeCenterCandidates(
|
||||
context,
|
||||
@@ -1035,6 +1042,67 @@ function getBestLocationCandidate(candidates) {
|
||||
})[0] || null;
|
||||
}
|
||||
|
||||
// The queue owns only entity contexts, so replacing or hiding a card cannot
|
||||
// detach the running work from its progress and results.
|
||||
async function runUnresolvedComputeCenterBatch(contexts) {
|
||||
setUnresolvedBatchState({
|
||||
running: true,
|
||||
total: contexts.length,
|
||||
processed: 0,
|
||||
saved: 0,
|
||||
missed: 0,
|
||||
statusText: infoText(`一键定位进行中 0/${contexts.length}...`),
|
||||
});
|
||||
|
||||
let savedCount = 0;
|
||||
let missedCount = 0;
|
||||
try {
|
||||
for (const [index, context] of contexts.entries()) {
|
||||
const progressText = infoText(`正在定位并采用最高置信候选 ${index + 1}/${contexts.length}...`);
|
||||
setUnresolvedBatchState({ statusText: progressText });
|
||||
try {
|
||||
const outcome = await saveBestUnresolvedComputeCenterCandidate(context, progressText);
|
||||
if (outcome.saved) savedCount += 1;
|
||||
else missedCount += 1;
|
||||
} catch (error) {
|
||||
console.error('adopt unresolved compute-center location failed', error);
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: infoText(`一键定位失败:${error?.message || error}`),
|
||||
candidates: [],
|
||||
});
|
||||
missedCount += 1;
|
||||
} finally {
|
||||
setUnresolvedBatchState({
|
||||
processed: index + 1,
|
||||
saved: savedCount,
|
||||
missed: missedCount,
|
||||
statusText: infoText(`一键定位进行中 ${index + 1}/${contexts.length},已保存 ${savedCount} 个${missedCount ? `,失败 ${missedCount} 个` : ''}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const finalStatus = savedCount > 0
|
||||
? infoText(`已定位并采用 ${savedCount} 个最高置信候选${missedCount ? `,${missedCount} 个仍需手动处理` : ''}`)
|
||||
: infoText(`${missedCount} 个都没有可自动采用的候选,需要手动处理`);
|
||||
setUnresolvedBatchState({ statusText: finalStatus });
|
||||
if (savedCount > 0) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:compute-center-location-saved', {
|
||||
detail: {
|
||||
entityType: 'compute_center',
|
||||
entityId: 'batch',
|
||||
savedCount,
|
||||
missedCount,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setUnresolvedBatchState({ running: false });
|
||||
}
|
||||
}
|
||||
|
||||
function bindComputeCenterUnresolvedControls(content) {
|
||||
content.querySelectorAll('[data-unresolved-item]').forEach((itemRoot) => {
|
||||
const collectButton = itemRoot.querySelector('[data-unresolved-collect]');
|
||||
@@ -1045,13 +1113,13 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
...context,
|
||||
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);
|
||||
},
|
||||
};
|
||||
ensureCandidateActionBindings(itemRoot, actionContext);
|
||||
hydrateLocationCollectRoot(itemRoot, getLocationCollectState(context));
|
||||
});
|
||||
|
||||
content.querySelectorAll('[data-unresolved-collect]').forEach((button) => {
|
||||
@@ -1067,6 +1135,7 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
const candidatesEl = itemRoot?.querySelector('[data-unresolved-candidates]');
|
||||
const context = JSON.parse(button.dataset.contextJson || '{}');
|
||||
if (!context.sourceId || !statusEl || !candidatesEl) return;
|
||||
if (computeCenterUnresolvedBatchState.running || getLocationCollectState(context)?.loading) return;
|
||||
|
||||
itemRoot?.classList.add('is-locating');
|
||||
setLocationButtonLoading(button, true, '正在定位');
|
||||
@@ -1096,7 +1165,6 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
...context,
|
||||
entityType: 'compute_center',
|
||||
entityId: context.sourceId,
|
||||
isUnresolved: true,
|
||||
save: (candidate) => mod.saveComputeCenterLocation(context.sourceId, candidate, context),
|
||||
};
|
||||
ensureCandidateActionBindings(itemRoot, actionContext);
|
||||
@@ -1128,102 +1196,11 @@ function bindComputeCenterUnresolvedControls(content) {
|
||||
updateUnresolvedBatchDom();
|
||||
return;
|
||||
}
|
||||
const statusEl = content.querySelector('[data-unresolved-batch-status]');
|
||||
const buttons = Array.from(content.querySelectorAll('button'));
|
||||
const pendingItems = Array.from(content.querySelectorAll('[data-unresolved-item]'))
|
||||
.map((itemRoot) => {
|
||||
const collectButton = itemRoot.querySelector('[data-unresolved-collect]');
|
||||
const context = JSON.parse(collectButton?.dataset.contextJson || '{}');
|
||||
return { itemRoot, context };
|
||||
})
|
||||
.filter(({ context }) => context.sourceId);
|
||||
|
||||
if (!pendingItems.length) return;
|
||||
buttons.forEach((button) => { button.disabled = true; });
|
||||
setLocationButtonLoading(adoptAllButton, true, '正在定位');
|
||||
setUnresolvedBatchState({
|
||||
running: true,
|
||||
total: pendingItems.length,
|
||||
processed: 0,
|
||||
saved: 0,
|
||||
missed: 0,
|
||||
statusText: infoText(`一键定位进行中 0/${pendingItems.length}...`),
|
||||
});
|
||||
|
||||
let savedCount = 0;
|
||||
let missedCount = 0;
|
||||
try {
|
||||
for (const [index, { itemRoot, context }] of pendingItems.entries()) {
|
||||
const itemStatusEl = itemRoot.querySelector('[data-unresolved-status]');
|
||||
itemRoot.classList.add('is-locating');
|
||||
const progressText = infoText(`正在定位并采用最高置信候选 ${index + 1}/${pendingItems.length}...`);
|
||||
setUnresolvedBatchState({
|
||||
statusText: progressText,
|
||||
processed: index,
|
||||
saved: savedCount,
|
||||
missed: missedCount,
|
||||
});
|
||||
if (statusEl) statusEl.textContent = progressText;
|
||||
try {
|
||||
const saveOutcome = await saveBestUnresolvedComputeCenterCandidate(context, progressText);
|
||||
if (!saveOutcome.saved) {
|
||||
if (itemStatusEl) itemStatusEl.textContent = getLocationCollectState(context)?.statusText || infoText('未找到可采用候选');
|
||||
missedCount += 1;
|
||||
continue;
|
||||
}
|
||||
savedCount += 1;
|
||||
removeResolvedUnresolvedItem(content, itemRoot);
|
||||
} catch (error) {
|
||||
console.error('adopt unresolved compute-center location failed', error);
|
||||
setLocationCollectState(context, {
|
||||
loading: false,
|
||||
statusText: infoText(`一键定位失败:${error?.message || error}`),
|
||||
candidates: [],
|
||||
});
|
||||
if (itemStatusEl) {
|
||||
itemStatusEl.textContent = infoText(`一键采用失败:${error?.message || error}`);
|
||||
}
|
||||
missedCount += 1;
|
||||
} finally {
|
||||
itemRoot.classList.remove('is-locating');
|
||||
setUnresolvedBatchState({
|
||||
processed: index + 1,
|
||||
saved: savedCount,
|
||||
missed: missedCount,
|
||||
statusText: infoText(`一键定位进行中 ${index + 1}/${pendingItems.length},已保存 ${savedCount} 个${missedCount ? `,失败 ${missedCount} 个` : ''}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const finalStatus = savedCount > 0
|
||||
? infoText(`已定位并采用 ${savedCount} 个最高置信候选${missedCount ? `,${missedCount} 个仍需手动处理` : ''}`)
|
||||
: infoText(`${missedCount} 个都没有可自动采用的候选,需要手动处理`);
|
||||
if (statusEl) statusEl.textContent = finalStatus;
|
||||
setUnresolvedBatchState({
|
||||
running: false,
|
||||
processed: pendingItems.length,
|
||||
saved: savedCount,
|
||||
missed: missedCount,
|
||||
statusText: finalStatus,
|
||||
});
|
||||
if (savedCount > 0) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('earth:compute-center-location-saved', {
|
||||
detail: {
|
||||
entityType: 'compute_center',
|
||||
entityId: 'batch',
|
||||
savedCount,
|
||||
missedCount,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setUnresolvedBatchState({ running: false });
|
||||
setLocationButtonLoading(adoptAllButton, false);
|
||||
buttons.forEach((button) => { button.disabled = false; });
|
||||
updateComputeCenterLocationCapabilityDom();
|
||||
}
|
||||
const contexts = Array.from(content.querySelectorAll('[data-unresolved-collect]'))
|
||||
.map((button) => JSON.parse(button.dataset.contextJson || '{}'))
|
||||
.filter((context) => context.sourceId && !getLocationCollectState(context)?.savedCandidate);
|
||||
if (!contexts.length || contexts.some((context) => getLocationCollectState(context)?.loading)) return;
|
||||
await runUnresolvedComputeCenterBatch(contexts);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const assetImageLoadPromises = new Map();
|
||||
const surfaceAvoidanceBuckets = new Map();
|
||||
const interactableLayerControllers = new Map();
|
||||
const DEFAULT_AVOIDANCE_PRECISION = 4;
|
||||
const POINT_BUCKET_MIN_CAPACITY = 32;
|
||||
const COMPACT_DOT_ZOOM_THRESHOLD = 1.7;
|
||||
const COMPACT_DOT_POINT_SIZE = 12;
|
||||
const COMPACT_DOT_RADIUS_RATIO = 0.26;
|
||||
@@ -812,6 +813,8 @@ export function createInteractableLayer(options = {}) {
|
||||
|
||||
const markers = [];
|
||||
const pointObjects = [];
|
||||
const markerById = new Map();
|
||||
const pointSlots = new WeakMap();
|
||||
const clusterPointObjects = [];
|
||||
const textureCache = new Map();
|
||||
let pointsGroup = null;
|
||||
@@ -1145,6 +1148,7 @@ export function createInteractableLayer(options = {}) {
|
||||
}
|
||||
|
||||
function beginClusterUpdate() {
|
||||
if (!clusterConfig.enabled) return;
|
||||
clusterUpdateActive = true;
|
||||
ownedClusterRecords.length = 0;
|
||||
markers.forEach((marker) => {
|
||||
@@ -1356,18 +1360,80 @@ export function createInteractableLayer(options = {}) {
|
||||
const bucketMarkers = points.userData?.markers || [];
|
||||
const colorAttribute = points.geometry?.getAttribute("color");
|
||||
if (!colorAttribute?.array) return;
|
||||
|
||||
let firstChanged = -1;
|
||||
let lastChanged = -1;
|
||||
bucketMarkers.forEach((marker, index) => {
|
||||
const pointColor =
|
||||
compactDotMode || icon.colorable !== false
|
||||
? getMarkerColor(marker)
|
||||
: "#ffffff";
|
||||
const [r, g, b] = colorToRgbArray(pointColor);
|
||||
colorAttribute.array[index * 3] = r;
|
||||
colorAttribute.array[index * 3 + 1] = g;
|
||||
colorAttribute.array[index * 3 + 2] = b;
|
||||
if (colorAttribute.getX(index) !== Math.fround(r)
|
||||
|| colorAttribute.getY(index) !== Math.fround(g)
|
||||
|| colorAttribute.getZ(index) !== Math.fround(b)) {
|
||||
colorAttribute.setXYZ(index, r, g, b);
|
||||
if (firstChanged < 0) firstChanged = index;
|
||||
lastChanged = index;
|
||||
}
|
||||
});
|
||||
colorAttribute.needsUpdate = true;
|
||||
if (firstChanged >= 0) markAttributeRange(colorAttribute, firstChanged * 3, (lastChanged - firstChanged + 1) * 3);
|
||||
}
|
||||
|
||||
function addPointBucket(bucketKey, bucketMarkers, compactDotMode = false) {
|
||||
const count = bucketMarkers.length;
|
||||
const positions = new Float32Array(count * 3);
|
||||
const colorValues = new Float32Array(count * 3);
|
||||
|
||||
bucketMarkers.forEach((marker, index) => {
|
||||
positions[index * 3] = marker.position.x;
|
||||
positions[index * 3 + 1] = marker.position.y;
|
||||
positions[index * 3 + 2] = marker.position.z;
|
||||
const pointColor =
|
||||
icon.colorable === false ? "#ffffff" : getMarkerColor(marker);
|
||||
const [r, g, b] = colorToRgbArray(pointColor);
|
||||
colorValues[index * 3] = r;
|
||||
colorValues[index * 3 + 1] = g;
|
||||
colorValues[index * 3 + 2] = b;
|
||||
});
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3).setUsage(THREE.DynamicDrawUsage));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(colorValues, 3).setUsage(THREE.DynamicDrawUsage));
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
const material = applyIconAnchor(
|
||||
new THREE.PointsMaterial({
|
||||
map: compactDotMode
|
||||
? createCompactDotTexture(bucketMarkers[0])
|
||||
: createPointTexture(bucketKey, bucketMarkers),
|
||||
size:
|
||||
(compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) *
|
||||
getPointSizeMultiplier(bucketMarkers[0]),
|
||||
sizeAttenuation: false,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: getPointOpacity?.(bucketMarkers[0]) ?? baseOpacity,
|
||||
depthWrite,
|
||||
depthTest,
|
||||
alphaTest,
|
||||
}),
|
||||
);
|
||||
|
||||
const points = new THREE.Points(geometry, material);
|
||||
points.renderOrder = renderOrder;
|
||||
points.frustumCulled = false;
|
||||
points.userData = {
|
||||
type: `${id}_points`,
|
||||
id,
|
||||
bucketKey,
|
||||
markers: bucketMarkers,
|
||||
pointSizeMultiplier: getPointSizeMultiplier(bucketMarkers[0]),
|
||||
transitionStartedAt: getNowMs(),
|
||||
};
|
||||
pointObjects.push(points);
|
||||
pointsGroup.add(points);
|
||||
bucketMarkers.forEach((marker, index) => pointSlots.set(marker, { points, index }));
|
||||
return points;
|
||||
}
|
||||
|
||||
function buildPoints(camera = null) {
|
||||
@@ -1391,58 +1457,7 @@ export function createInteractableLayer(options = {}) {
|
||||
});
|
||||
|
||||
buckets.forEach((bucketMarkers, bucketKey) => {
|
||||
const count = bucketMarkers.length;
|
||||
const positions = new Float32Array(count * 3);
|
||||
const colorValues = new Float32Array(count * 3);
|
||||
|
||||
bucketMarkers.forEach((marker, index) => {
|
||||
positions[index * 3] = marker.position.x;
|
||||
positions[index * 3 + 1] = marker.position.y;
|
||||
positions[index * 3 + 2] = marker.position.z;
|
||||
const pointColor =
|
||||
icon.colorable === false ? "#ffffff" : getMarkerColor(marker);
|
||||
const [r, g, b] = colorToRgbArray(pointColor);
|
||||
colorValues[index * 3] = r;
|
||||
colorValues[index * 3 + 1] = g;
|
||||
colorValues[index * 3 + 2] = b;
|
||||
});
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(colorValues, 3));
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
const material = applyIconAnchor(
|
||||
new THREE.PointsMaterial({
|
||||
map: compactDotMode
|
||||
? createCompactDotTexture(bucketMarkers[0])
|
||||
: createPointTexture(bucketKey, bucketMarkers),
|
||||
size:
|
||||
(compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) *
|
||||
getPointSizeMultiplier(bucketMarkers[0]),
|
||||
sizeAttenuation: false,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: getPointOpacity?.(bucketMarkers[0]) ?? baseOpacity,
|
||||
depthWrite,
|
||||
depthTest,
|
||||
alphaTest,
|
||||
}),
|
||||
);
|
||||
|
||||
const points = new THREE.Points(geometry, material);
|
||||
points.renderOrder = renderOrder;
|
||||
points.frustumCulled = false;
|
||||
points.userData = {
|
||||
type: `${id}_points`,
|
||||
id,
|
||||
bucketKey,
|
||||
markers: bucketMarkers,
|
||||
pointSizeMultiplier: getPointSizeMultiplier(bucketMarkers[0]),
|
||||
transitionStartedAt: getNowMs(),
|
||||
};
|
||||
pointObjects.push(points);
|
||||
pointsGroup.add(points);
|
||||
addPointBucket(bucketKey, bucketMarkers, compactDotMode);
|
||||
});
|
||||
|
||||
group.add(pointsGroup);
|
||||
@@ -1612,6 +1627,7 @@ export function createInteractableLayer(options = {}) {
|
||||
invalidateClusterTopology();
|
||||
unregisterLayerAvoidance(id);
|
||||
markers.length = 0;
|
||||
markerById.clear();
|
||||
clearRenderObjects();
|
||||
disposeGroupChildren(group);
|
||||
|
||||
@@ -1636,6 +1652,7 @@ export function createInteractableLayer(options = {}) {
|
||||
state: "normal",
|
||||
};
|
||||
markers.push(marker);
|
||||
markerById.set(String(getItemId(marker.userData)), marker);
|
||||
});
|
||||
|
||||
registerLayerAvoidance(id, markers, avoidanceConfig);
|
||||
@@ -1649,6 +1666,116 @@ export function createInteractableLayer(options = {}) {
|
||||
group.visible = wasVisible;
|
||||
}
|
||||
|
||||
function markAttributeRange(attribute, offset, count) {
|
||||
const range = attribute.updateRange;
|
||||
const start = range.count < 0 ? offset : Math.min(offset, range.offset);
|
||||
const end = range.count < 0 ? offset + count : Math.max(offset + count, range.offset + range.count);
|
||||
range.offset = start;
|
||||
range.count = end - start;
|
||||
attribute.needsUpdate = true;
|
||||
}
|
||||
|
||||
function syncPointBucket(bucketKey, bucketMarkers) {
|
||||
let points = pointObjects.find((entry) => entry.userData.bucketKey === bucketKey);
|
||||
if (!points) {
|
||||
if (bucketMarkers.length) addPointBucket(bucketKey, bucketMarkers);
|
||||
return;
|
||||
}
|
||||
const currentCapacity = points.geometry.attributes.position.count;
|
||||
if (currentCapacity < bucketMarkers.length) {
|
||||
const capacity = Math.max(bucketMarkers.length, currentCapacity * 2, POINT_BUCKET_MIN_CAPACITY);
|
||||
points.geometry.dispose();
|
||||
points.geometry = new THREE.BufferGeometry();
|
||||
for (const name of ["position", "color"]) {
|
||||
points.geometry.setAttribute(name, new THREE.BufferAttribute(new Float32Array(capacity * 3), 3)
|
||||
.setUsage(THREE.DynamicDrawUsage));
|
||||
}
|
||||
}
|
||||
points.userData.markers = bucketMarkers;
|
||||
points.geometry.setDrawRange(0, bucketMarkers.length);
|
||||
const positions = points.geometry.attributes.position;
|
||||
bucketMarkers.forEach((marker, index) => {
|
||||
positions.setXYZ(index, marker.position.x, marker.position.y, marker.position.z);
|
||||
pointSlots.set(marker, { points, index });
|
||||
});
|
||||
if (bucketMarkers.length) markAttributeRange(positions, 0, bucketMarkers.length * 3);
|
||||
updatePointColors(points, false);
|
||||
}
|
||||
|
||||
function updateItems(items, { replace = false } = {}) {
|
||||
if (clusterConfig.enabled || avoidanceConfig.enabled) {
|
||||
throw new Error(`Incremental updates require an unclustered layer: ${id}`);
|
||||
}
|
||||
if (!pointsGroup) {
|
||||
setData(items);
|
||||
return;
|
||||
}
|
||||
const affectedBuckets = new Set();
|
||||
const updatedMarkers = [];
|
||||
const incomingIds = new Set();
|
||||
const radius = CONFIG.earthRadius + altitudeOffset;
|
||||
for (const item of items) {
|
||||
const itemId = String(getItemId(item) ?? "");
|
||||
if (!itemId) continue;
|
||||
const position = normalizePosition(getPosition(item), radius);
|
||||
if (!position) continue;
|
||||
incomingIds.add(itemId);
|
||||
let marker = markerById.get(itemId);
|
||||
const previousBucket = marker ? getBucketKey(marker) : null;
|
||||
if (!marker) {
|
||||
marker = new THREE.Object3D();
|
||||
markers.push(marker);
|
||||
markerById.set(itemId, marker);
|
||||
}
|
||||
marker.position.copy(position);
|
||||
marker.userData = {
|
||||
...marker.userData,
|
||||
...getUserData(item),
|
||||
type: objectType,
|
||||
icon_layer_id: id,
|
||||
icon_kind: getKind(item),
|
||||
icon_base_position: position,
|
||||
state: marker.userData.state || "normal",
|
||||
};
|
||||
const nextBucket = getBucketKey(marker);
|
||||
if (previousBucket !== nextBucket) {
|
||||
if (previousBucket !== null) affectedBuckets.add(previousBucket);
|
||||
affectedBuckets.add(nextBucket);
|
||||
}
|
||||
updatedMarkers.push(marker);
|
||||
}
|
||||
if (replace) {
|
||||
let writeIndex = 0;
|
||||
for (const marker of markers) {
|
||||
const itemId = String(getItemId(marker.userData));
|
||||
if (incomingIds.has(itemId)) markers[writeIndex++] = marker;
|
||||
else {
|
||||
affectedBuckets.add(getBucketKey(marker));
|
||||
markerById.delete(itemId);
|
||||
}
|
||||
}
|
||||
markers.length = writeIndex;
|
||||
}
|
||||
if (affectedBuckets.size) {
|
||||
const buckets = new Map(Array.from(affectedBuckets, (key) => [key, []]));
|
||||
markers.forEach((marker) => buckets.get(getBucketKey(marker))?.push(marker));
|
||||
buckets.forEach((bucketMarkers, key) => syncPointBucket(key, bucketMarkers));
|
||||
}
|
||||
updatedMarkers.forEach((marker) => {
|
||||
if (affectedBuckets.has(getBucketKey(marker))) return;
|
||||
const slot = pointSlots.get(marker);
|
||||
if (!slot) return;
|
||||
const positions = slot.points.geometry.attributes.position;
|
||||
if (positions.getX(slot.index) !== Math.fround(marker.position.x)
|
||||
|| positions.getY(slot.index) !== Math.fround(marker.position.y)
|
||||
|| positions.getZ(slot.index) !== Math.fround(marker.position.z)) {
|
||||
positions.setXYZ(slot.index, marker.position.x, marker.position.y, marker.position.z);
|
||||
markAttributeRange(positions, slot.index * 3, 3);
|
||||
}
|
||||
});
|
||||
invalidateVisualState();
|
||||
}
|
||||
|
||||
function upsertItem(item) {
|
||||
const itemId = getItemId(item);
|
||||
if (itemId === undefined || itemId === null || String(itemId).trim() === "") {
|
||||
@@ -1722,6 +1849,7 @@ export function createInteractableLayer(options = {}) {
|
||||
invalidateClusterTopology();
|
||||
unregisterLayerAvoidance(id);
|
||||
markers.length = 0;
|
||||
markerById.clear();
|
||||
clearRenderObjects();
|
||||
disposeGroupChildren(group);
|
||||
if (parent && group.parent === parent) {
|
||||
@@ -1807,7 +1935,7 @@ export function createInteractableLayer(options = {}) {
|
||||
points.material.needsUpdate = true;
|
||||
}
|
||||
updatePointColors(points, compactDotMode);
|
||||
points.visible = visible;
|
||||
points.visible = visible && points.userData.markers.length > 0;
|
||||
const transitionProgress = getTransitionProgress(
|
||||
points.userData?.transitionStartedAt,
|
||||
clusterConfig.transitionMs,
|
||||
@@ -1995,6 +2123,7 @@ export function createInteractableLayer(options = {}) {
|
||||
getCount: () => markers.length,
|
||||
isVisible: () => visible,
|
||||
setData,
|
||||
updateItems,
|
||||
upsertItem,
|
||||
removeItem,
|
||||
preloadAssets,
|
||||
|
||||
@@ -200,6 +200,7 @@ import {
|
||||
updateComputeCenterVisualState,
|
||||
} from "./compute-centers.js";
|
||||
import {
|
||||
applyVesselRealtimeFrame,
|
||||
clearVesselData,
|
||||
clearVesselSelection,
|
||||
getShowVessels,
|
||||
@@ -209,6 +210,7 @@ import {
|
||||
getVesselPointerIntersections as getVesselIconPointerIntersections,
|
||||
getVesselRealtimeStats,
|
||||
loadVessels,
|
||||
setVesselRealtimeConnected,
|
||||
setVesselMarkerState,
|
||||
showVesselTrack,
|
||||
startVesselRealtime,
|
||||
@@ -274,6 +276,7 @@ import { createNewsCruiseAdapter } from "./news-cruise-adapter.js";
|
||||
import { createMotionCruiseAdapter } from "./motion-cruise-adapter.js";
|
||||
import { PresentationController } from "./presentation-controller.js";
|
||||
import {
|
||||
getComputeCenterLocationBatchState,
|
||||
initInfoCard,
|
||||
showInfoCard,
|
||||
hideInfoCard,
|
||||
@@ -2757,11 +2760,12 @@ function updateComputeCenterHud(computeCenterResult) {
|
||||
|
||||
function updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount) {
|
||||
const count = Math.max(0, Number(unresolvedCount) || 0);
|
||||
const batch = getComputeCenterLocationBatchState();
|
||||
const layerRow = computeBtn.closest(".layer-row");
|
||||
if (!layerRow) return;
|
||||
let badge = layerRow.querySelector("[data-compute-center-unresolved-badge]");
|
||||
|
||||
if (count <= 0) {
|
||||
if (count <= 0 && batch.total <= 0) {
|
||||
delete computeBtn.dataset.unresolvedCount;
|
||||
badge?.remove();
|
||||
return;
|
||||
@@ -2782,8 +2786,8 @@ function updateComputeCenterUnresolvedBadge(computeBtn, unresolvedCount) {
|
||||
};
|
||||
|
||||
computeBtn.dataset.unresolvedCount = String(count);
|
||||
badge.textContent = count > 99 ? "99+" : String(count);
|
||||
const title = formatUnresolvedComputeTitle(count);
|
||||
badge.textContent = count > 0 ? (count > 99 ? "99+" : String(count)) : (batch.running ? "…" : "✓");
|
||||
const title = count > 0 ? formatUnresolvedComputeTitle(count) : translateText(batch.statusText);
|
||||
badge.title = title;
|
||||
badge.setAttribute(
|
||||
"aria-label",
|
||||
@@ -3956,6 +3960,25 @@ function applyBGPEmptyState() {
|
||||
function updateVesselHud(result = {}) {
|
||||
const count = Number(result.totalCount ?? getVesselCount() ?? 0);
|
||||
setEarthStatValue("vessel-count", `${count} 艘`);
|
||||
if (getShowVessels()) startVisibleVesselRealtime();
|
||||
}
|
||||
|
||||
function reconcileVesselSelection() {
|
||||
if (lockedObjectType === "vessel" && lockedObject && !getVesselMarkers().includes(lockedObject)) {
|
||||
clearLockedObject();
|
||||
hideInfoCard();
|
||||
}
|
||||
}
|
||||
|
||||
function startVisibleVesselRealtime() {
|
||||
startVesselRealtime(getEarth(), {
|
||||
onUpdate: ({ totalCount }) => {
|
||||
reconcileVesselSelection();
|
||||
updateVesselToggleUi(true, totalCount);
|
||||
updateStatsSummary();
|
||||
},
|
||||
});
|
||||
syncVesselRealtimeSubscription();
|
||||
}
|
||||
|
||||
function formatRelativeTime(value) {
|
||||
@@ -3974,9 +3997,9 @@ function formatVesselLiveSummary() {
|
||||
if (stream.connected) {
|
||||
const lastUpdate = formatRelativeTime(stream.lastUpdateAt);
|
||||
if (stream.updates > 0) {
|
||||
return `AISStream 实时已连接 · ${stream.updates} 次更新${lastUpdate ? ` · ${lastUpdate}` : ""}`;
|
||||
return `船舶实时通道已连接 · ${stream.updates} 次更新${lastUpdate ? ` · ${lastUpdate}` : ""}`;
|
||||
}
|
||||
return "AISStream 实时已连接 · 等待首批更新";
|
||||
return "船舶实时通道已连接 · 等待首批更新";
|
||||
}
|
||||
const state = earthStatsSummary?.aisstreamConnectionState;
|
||||
if (state === "connected") {
|
||||
@@ -4131,12 +4154,7 @@ async function ensureVesselsEnabled() {
|
||||
const zoom = getZoomLevel();
|
||||
const result = await loadVessels(scene, earth, { zoom });
|
||||
toggleVessels(true);
|
||||
startVesselRealtime(earth, {
|
||||
onUpdate: ({ totalCount }) => {
|
||||
updateVesselToggleUi(true, totalCount);
|
||||
updateStatsSummary();
|
||||
},
|
||||
});
|
||||
startVisibleVesselRealtime();
|
||||
updateVesselToggleUi(true, result.totalCount);
|
||||
setLegendItems("vessels", getVesselLegendItems());
|
||||
refreshLegend();
|
||||
@@ -4147,6 +4165,7 @@ function disableVessels() {
|
||||
vesselsEnabled = false;
|
||||
stopVesselRealtime();
|
||||
toggleVessels(false);
|
||||
syncVesselRealtimeSubscription();
|
||||
clearVesselSelection();
|
||||
updateVesselToggleUi(false, 0);
|
||||
setLegendItems("vessels", getVesselLegendItems());
|
||||
@@ -4171,7 +4190,9 @@ function updateStatsSummary() {
|
||||
getLandingPoints().length,
|
||||
);
|
||||
const satelliteCount = getAuthoritativeSatelliteCount();
|
||||
const vesselCount = getAuthoritativeCount("vesselCount", getVesselCount());
|
||||
const vesselCount = getVesselRealtimeStats().connected
|
||||
? getVesselCount()
|
||||
: getAuthoritativeCount("vesselCount", getVesselCount());
|
||||
const computeCenterCount = getAuthoritativeCount(
|
||||
"computeCenterCount",
|
||||
getComputeCenterCount(),
|
||||
@@ -4660,7 +4681,7 @@ async function refreshDataBackedEarthLayer(layer, options = {}) {
|
||||
if (shouldClearBeforeReload) {
|
||||
handler.clear?.();
|
||||
}
|
||||
if (!shouldClearBeforeReload && shouldClearLayerFromServerState(handler)) {
|
||||
if (layer !== "vessels" && !shouldClearBeforeReload && shouldClearLayerFromServerState(handler)) {
|
||||
handler.clear?.();
|
||||
return true;
|
||||
}
|
||||
@@ -4759,6 +4780,7 @@ function handleEarthUpdateFrame(payload = {}) {
|
||||
const isDatabaseChange = payload.action === "database_changed";
|
||||
const strategy = typeof payload.refresh_strategy === "string" ? payload.refresh_strategy : "";
|
||||
layers.forEach((layer) => {
|
||||
if (layer === "vessels" && strategy === "delta" && getVesselRealtimeStats().connected) return;
|
||||
pendingEarthUpdateLayers.add(layer);
|
||||
if (strategy === "clear_then_reload" || !pendingEarthUpdateStrategies.has(layer)) {
|
||||
pendingEarthUpdateStrategies.set(layer, strategy || "reload");
|
||||
@@ -4781,6 +4803,11 @@ async function reconcileEarthDataFromServer() {
|
||||
|
||||
let changed = false;
|
||||
for (const [layer, handler] of Object.entries(EARTH_DATA_LAYER_HANDLERS)) {
|
||||
if (layer === "vessels" && handler.isVisible?.()) {
|
||||
await refreshDataBackedEarthLayer(layer, { force: true });
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (shouldClearLayerFromServerState(handler) && handler.localCount?.() > 0) {
|
||||
handler.clear?.();
|
||||
changed = true;
|
||||
@@ -4818,6 +4845,19 @@ function startEarthDataReconciliation() {
|
||||
);
|
||||
}
|
||||
|
||||
function syncVesselRealtimeSubscription() {
|
||||
const socket = earthUpdatesSocket;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
const enabled = getShowVessels();
|
||||
if (Boolean(socket.__planetVesselSubscription) === enabled) return;
|
||||
socket.__planetVesselSubscription = enabled;
|
||||
socket.send(JSON.stringify({
|
||||
type: enabled ? "subscribe" : "unsubscribe",
|
||||
data: { channels: ["vessels"], scope: "global", zoom: 4 },
|
||||
}));
|
||||
if (!enabled) setVesselRealtimeConnected(false);
|
||||
}
|
||||
|
||||
function connectEarthUpdatesRealtime() {
|
||||
if (earthUpdatesSocket || destroyed) return;
|
||||
if (!canAttemptEarthRealtime()) {
|
||||
@@ -4839,6 +4879,7 @@ function connectEarthUpdatesRealtime() {
|
||||
channel: EARTH_UPDATES_CHANNEL,
|
||||
},
|
||||
}));
|
||||
syncVesselRealtimeSubscription();
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
let message;
|
||||
@@ -4851,11 +4892,23 @@ function connectEarthUpdatesRealtime() {
|
||||
socket.send(JSON.stringify({ type: "heartbeat" }));
|
||||
return;
|
||||
}
|
||||
if (message.type !== "data_frame" || message.channel !== EARTH_UPDATES_CHANNEL) return;
|
||||
handleEarthUpdateFrame(message.payload || {});
|
||||
if (message.type === "subscription_confirmed" && message.data?.channels?.includes("vessels")) {
|
||||
const connected = message.data.action === "subscribe" && message.data.vessels?.scope === "global";
|
||||
setVesselRealtimeConnected(connected);
|
||||
if (connected && getShowVessels()) {
|
||||
loadVessels(scene, getEarth(), { zoom: getZoomLevel() }).then((result) => {
|
||||
if (!destroyed && getShowVessels()) updateVesselHud(result);
|
||||
}).catch((error) => console.warn("船舶重连校准失败:", error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type !== "data_frame") return;
|
||||
if (message.channel === "vessels") applyVesselRealtimeFrame(message.payload || {}, message.timestamp);
|
||||
else if (message.channel === EARTH_UPDATES_CHANNEL) handleEarthUpdateFrame(message.payload || {});
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (earthUpdatesSocket === socket) {
|
||||
setVesselRealtimeConnected(false);
|
||||
earthUpdatesSocket = null;
|
||||
}
|
||||
if (!socket.__planetOpened) {
|
||||
@@ -5445,6 +5498,7 @@ function setupEventListeners() {
|
||||
"earth:compute-center-unresolved-count-change",
|
||||
handleComputeCenterUnresolvedCountChange,
|
||||
);
|
||||
bindListener(window, "earth:compute-center-location-batch-change", () => syncComputeCenterUnresolvedCount());
|
||||
bindListener(window, "earth:motion-debug-mode-change", handleMotionDebugModeChange);
|
||||
bindListener(window, MOTION_DEBUG_CLOSE_EVENT, handleMotionDebugClose);
|
||||
getCruiseModuleDefinitions().forEach((module) => {
|
||||
@@ -6356,6 +6410,7 @@ function animate() {
|
||||
export function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
stopVesselRealtime();
|
||||
currentLoadToken += 1;
|
||||
isDataLoading = false;
|
||||
setGlobeDraggingUiState(false);
|
||||
|
||||
55
frontend/public/earth/js/satellite-position-worker.js
Normal file
55
frontend/public/earth/js/satellite-position-worker.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { SATELLITE_CONFIG } from "./constants.js";
|
||||
import {
|
||||
createSatellitePropagator,
|
||||
getSatelliteSampleTimeMs,
|
||||
SATELLITE_POSITION_UPDATE_INTERVAL_MS,
|
||||
} from "./satellite-propagation.js";
|
||||
|
||||
let satellites = [];
|
||||
let propagator = null;
|
||||
|
||||
self.onmessage = async ({ data }) => {
|
||||
try {
|
||||
if (data.type === "init") {
|
||||
// Import maps belong to documents; resolve their URLs on the main thread
|
||||
// so the worker uses exactly the same Three.js and SGP4 versions.
|
||||
const [three, orbitalMath] = await Promise.all([
|
||||
import(data.threeUrl),
|
||||
import(data.satelliteUrl),
|
||||
]);
|
||||
satellites = data.satellites;
|
||||
propagator = createSatellitePropagator(three, orbitalMath);
|
||||
propagator.setRealAltitudeEnabled(data.realAltitude);
|
||||
self.postMessage({ type: "ready" });
|
||||
return;
|
||||
}
|
||||
if (data.type !== "update" || !propagator) return;
|
||||
const count = satellites.length;
|
||||
const positions = new Float32Array(count * 3);
|
||||
const trailSeed = data.seedTrails
|
||||
? new Float32Array(count * SATELLITE_CONFIG.trailLength * 3)
|
||||
: null;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const satellite = satellites[index];
|
||||
const timestamp = getSatelliteSampleTimeMs(data.baseTime, index, count);
|
||||
const time = new Date(timestamp);
|
||||
const position = propagator.computeSatellitePosition(satellite, time)
|
||||
|| propagator.generateFallbackPosition(satellite, index, count, time);
|
||||
position.toArray(positions, index * 3);
|
||||
if (trailSeed) {
|
||||
for (let step = 0; step < SATELLITE_CONFIG.trailLength; step += 1) {
|
||||
const pastTime = new Date(timestamp
|
||||
- (SATELLITE_CONFIG.trailLength - 1 - step) * SATELLITE_POSITION_UPDATE_INTERVAL_MS);
|
||||
const past = propagator.computeSatellitePosition(satellite, pastTime)
|
||||
|| propagator.generateFallbackPosition(satellite, index, count, pastTime);
|
||||
past.toArray(trailSeed, (index * SATELLITE_CONFIG.trailLength + step) * 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
const transfers = [positions.buffer];
|
||||
if (trailSeed) transfers.push(trailSeed.buffer);
|
||||
self.postMessage({ type: "positions", baseTime: data.baseTime, positions, trailSeed }, transfers);
|
||||
} catch (error) {
|
||||
self.postMessage({ type: "error", message: error?.message || String(error) });
|
||||
}
|
||||
};
|
||||
290
frontend/public/earth/js/satellite-propagation.js
Normal file
290
frontend/public/earth/js/satellite-propagation.js
Normal file
@@ -0,0 +1,290 @@
|
||||
import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
|
||||
|
||||
export const SATELLITE_POSITION_UPDATE_INTERVAL_MS = 250;
|
||||
export const EARTH_RADIUS_KM = 6378.137;
|
||||
|
||||
export function getSatelliteSampleTimeMs(baseTime, index, count) {
|
||||
return Math.trunc(baseTime) + (index / count) * 2 * Math.PI * 0.1 * 1000 * 60 * 10;
|
||||
}
|
||||
|
||||
export function createSatellitePropagator(THREE, { twoline2satrec, propagate, eciToEcf, gstime }) {
|
||||
let satelliteSatrecCache = new WeakMap();
|
||||
let satelliteRealAltitudeEnabled = true;
|
||||
const FALLBACK_MIN_MEAN_MOTION = 12;
|
||||
const FALLBACK_MEAN_MOTION_SPREAD = 4;
|
||||
const FALLBACK_ORBIT_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function computeSatellitePosition(satellite, time) {
|
||||
try {
|
||||
const props = satellite.properties;
|
||||
if (!props || !props.norad_cat_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const satrec = getOrBuildSatrec(props, time);
|
||||
if (!satrec || satrec.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const positionAndVelocity = propagate(satrec, time);
|
||||
if (!positionAndVelocity || !positionAndVelocity.position) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return computeDisplayPositionFromEciPosition(
|
||||
positionAndVelocity.position,
|
||||
gstime(time),
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function computeDisplayPositionFromEciPosition(positionEci, siderealTime) {
|
||||
const earthFixedPosition = convertEciPositionToSceneVector(
|
||||
positionEci,
|
||||
siderealTime,
|
||||
);
|
||||
const x = earthFixedPosition.x;
|
||||
const y = earthFixedPosition.y;
|
||||
const z = earthFixedPosition.z;
|
||||
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const r = Math.sqrt(
|
||||
positionEci.x * positionEci.x +
|
||||
positionEci.y * positionEci.y +
|
||||
positionEci.z * positionEci.z,
|
||||
);
|
||||
if (!Number.isFinite(r) || r <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayRadius = satelliteRealAltitudeEnabled
|
||||
? CONFIG.earthRadius + getCompressedRealAltitudeOffset(r)
|
||||
: CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
|
||||
const sceneRadius = Math.sqrt(x * x + y * y + z * z);
|
||||
if (!Number.isFinite(sceneRadius) || sceneRadius <= 0) {
|
||||
return null;
|
||||
}
|
||||
const scale = displayRadius / sceneRadius;
|
||||
|
||||
return new THREE.Vector3(x * scale, y * scale, z * scale);
|
||||
}
|
||||
|
||||
function computeSatelliteInertialOrbitPosition(satellite, time, siderealTime) {
|
||||
try {
|
||||
const props = satellite.properties;
|
||||
if (!props || !props.norad_cat_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const satrec = getOrBuildSatrec(props, time);
|
||||
if (!satrec || satrec.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const positionAndVelocity = propagate(satrec, time);
|
||||
if (!positionAndVelocity || !positionAndVelocity.position) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return computeDisplayPositionFromEciPosition(
|
||||
positionAndVelocity.position,
|
||||
siderealTime,
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function convertEciPositionToSceneVector(positionEci, siderealTime) {
|
||||
const positionEcf = eciToEcf(positionEci, siderealTime);
|
||||
return new THREE.Vector3(
|
||||
positionEcf.x,
|
||||
positionEcf.z,
|
||||
-positionEcf.y,
|
||||
);
|
||||
}
|
||||
|
||||
function getCompressedRealAltitudeOffset(radiusKm) {
|
||||
const altitudeKm = Math.max(0, radiusKm - EARTH_RADIUS_KM);
|
||||
const clampedAltitudeKm = Math.min(
|
||||
altitudeKm,
|
||||
SATELLITE_CONFIG.maxDisplayAltitudeKm,
|
||||
);
|
||||
const compressionKm = Math.max(1, SATELLITE_CONFIG.altitudeCompressionKm);
|
||||
const normalizedAltitude = Math.log1p(clampedAltitudeKm / compressionKm) /
|
||||
Math.log1p(SATELLITE_CONFIG.maxDisplayAltitudeKm / compressionKm);
|
||||
|
||||
return THREE.MathUtils.lerp(
|
||||
SATELLITE_CONFIG.minRealAltitudeOffset,
|
||||
SATELLITE_CONFIG.maxRealAltitudeOffset,
|
||||
THREE.MathUtils.clamp(normalizedAltitude, 0, 1),
|
||||
);
|
||||
}
|
||||
|
||||
function buildSatrecFromProperties(props, fallbackTime) {
|
||||
if (props.tle_line1 && props.tle_line2) {
|
||||
// Prefer source-provided TLE lines so the client does not need to rebuild them.
|
||||
const satrec = twoline2satrec(props.tle_line1, props.tle_line2);
|
||||
if (!satrec.error) {
|
||||
return satrec;
|
||||
}
|
||||
}
|
||||
|
||||
const tleLines = buildTleLinesFromElements(props, fallbackTime);
|
||||
if (!tleLines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return twoline2satrec(tleLines.line1, tleLines.line2);
|
||||
}
|
||||
|
||||
function getOrBuildSatrec(props, fallbackTime) {
|
||||
// Properties are immutable within a loaded snapshot. Avoid constructing and
|
||||
// hashing long TLE strings for every satellite on every position update.
|
||||
const cacheable = props?.epoch || (props?.tle_line1 && props?.tle_line2);
|
||||
if (cacheable && satelliteSatrecCache.has(props)) {
|
||||
return satelliteSatrecCache.get(props);
|
||||
}
|
||||
|
||||
const satrec = buildSatrecFromProperties(props, fallbackTime);
|
||||
if (cacheable) {
|
||||
satelliteSatrecCache.set(props, satrec);
|
||||
}
|
||||
return satrec;
|
||||
}
|
||||
|
||||
function computeTleChecksum(line) {
|
||||
let sum = 0;
|
||||
|
||||
for (const char of line.slice(0, 68)) {
|
||||
if (char >= "0" && char <= "9") {
|
||||
sum += Number(char);
|
||||
} else if (char === "-") {
|
||||
sum += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return String(sum % 10);
|
||||
}
|
||||
|
||||
function buildTleLinesFromElements(props, fallbackTime) {
|
||||
if (!props?.norad_cat_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requiredValues = [
|
||||
props.inclination,
|
||||
props.raan,
|
||||
props.eccentricity,
|
||||
props.arg_of_perigee,
|
||||
props.mean_anomaly,
|
||||
props.mean_motion,
|
||||
];
|
||||
if (requiredValues.some((value) => value === null || value === undefined)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const epochDate =
|
||||
props.epoch && String(props.epoch).length >= 10
|
||||
? new Date(props.epoch)
|
||||
: fallbackTime;
|
||||
if (Number.isNaN(epochDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const epochYear = epochDate.getUTCFullYear() % 100;
|
||||
const startOfYear = new Date(Date.UTC(epochDate.getUTCFullYear(), 0, 1));
|
||||
const dayOfYear = Math.floor((epochDate - startOfYear) / 86400000) + 1;
|
||||
const msOfDay =
|
||||
epochDate.getUTCHours() * 3600000 +
|
||||
epochDate.getUTCMinutes() * 60000 +
|
||||
epochDate.getUTCSeconds() * 1000 +
|
||||
epochDate.getUTCMilliseconds();
|
||||
const dayFraction = msOfDay / 86400000;
|
||||
const epochStr =
|
||||
String(epochYear).padStart(2, "0") +
|
||||
String(dayOfYear).padStart(3, "0") +
|
||||
dayFraction.toFixed(8).slice(1);
|
||||
|
||||
const eccentricityDigits = Math.round(Number(props.eccentricity) * 1e7)
|
||||
.toString()
|
||||
.padStart(7, "0");
|
||||
|
||||
// Keep a local fallback for historical rows that do not have stored TLE lines yet.
|
||||
const line1Core = `1 ${String(props.norad_cat_id).padStart(5, "0")}U 00001A ${epochStr} .00000000 00000-0 00000-0 0 999`;
|
||||
const line2Core = `2 ${String(props.norad_cat_id).padStart(5, "0")} ${Number(
|
||||
props.inclination,
|
||||
)
|
||||
.toFixed(4)
|
||||
.padStart(
|
||||
8,
|
||||
)} ${Number(props.raan).toFixed(4).padStart(8)} ${eccentricityDigits} ${Number(
|
||||
props.arg_of_perigee,
|
||||
)
|
||||
.toFixed(4)
|
||||
.padStart(8)} ${Number(props.mean_anomaly).toFixed(4).padStart(8)} ${Number(
|
||||
props.mean_motion,
|
||||
)
|
||||
.toFixed(8)
|
||||
.padStart(11)}00000`;
|
||||
|
||||
return {
|
||||
line1: line1Core + computeTleChecksum(line1Core),
|
||||
line2: line2Core + computeTleChecksum(line2Core),
|
||||
};
|
||||
}
|
||||
|
||||
function generateFallbackPosition(satellite, index, total, time = new Date()) {
|
||||
const radius = CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
|
||||
|
||||
const noradId = satellite.properties?.norad_cat_id || index;
|
||||
const inclination = satellite.properties?.inclination || 53;
|
||||
const raan = satellite.properties?.raan || 0;
|
||||
const meanAnomaly = satellite.properties?.mean_anomaly || 0;
|
||||
|
||||
const hash = String(noradId)
|
||||
.split("")
|
||||
.reduce((a, b) => a + b.charCodeAt(0), 0);
|
||||
const randomOffset = (hash % 1000) / 1000;
|
||||
const rawMeanMotion = Number(satellite.properties?.mean_motion);
|
||||
const meanMotion =
|
||||
Number.isFinite(rawMeanMotion) && rawMeanMotion > 0
|
||||
? rawMeanMotion
|
||||
: FALLBACK_MIN_MEAN_MOTION + randomOffset * FALLBACK_MEAN_MOTION_SPREAD;
|
||||
|
||||
const normalizedIndex = index / total;
|
||||
const elapsedDays = Number.isFinite(time?.getTime?.())
|
||||
? time.getTime() / FALLBACK_ORBIT_DAY_MS
|
||||
: Date.now() / FALLBACK_ORBIT_DAY_MS;
|
||||
const fallbackPhase = elapsedDays * meanMotion * Math.PI * 2;
|
||||
const theta =
|
||||
normalizedIndex * Math.PI * 2 * 10 +
|
||||
(raan * Math.PI) / 180 +
|
||||
fallbackPhase;
|
||||
const phi =
|
||||
(inclination * Math.PI) / 180 + ((meanAnomaly * Math.PI) / 180) * 0.1;
|
||||
|
||||
const adjustedPhi = Math.abs(phi % Math.PI);
|
||||
const adjustedTheta = theta + randomOffset * Math.PI * 2;
|
||||
|
||||
const x = radius * Math.sin(adjustedPhi) * Math.cos(adjustedTheta);
|
||||
const y = radius * Math.cos(adjustedPhi);
|
||||
const z = radius * Math.sin(adjustedPhi) * Math.sin(adjustedTheta);
|
||||
|
||||
return new THREE.Vector3(x, y, z);
|
||||
}
|
||||
|
||||
return {
|
||||
computeSatellitePosition,
|
||||
computeSatelliteInertialOrbitPosition,
|
||||
generateFallbackPosition,
|
||||
getOrBuildSatrec,
|
||||
reset() { satelliteSatrecCache = new WeakMap(); },
|
||||
setRealAltitudeEnabled(enabled) { satelliteRealAltitudeEnabled = enabled; },
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,16 @@ import {
|
||||
updateIridiumFootprintAdapter,
|
||||
} from "./iridium-footprint-adapter.js";
|
||||
|
||||
import {
|
||||
createSatellitePropagator,
|
||||
EARTH_RADIUS_KM,
|
||||
getSatelliteSampleTimeMs,
|
||||
SATELLITE_POSITION_UPDATE_INTERVAL_MS,
|
||||
} from "./satellite-propagation.js";
|
||||
|
||||
const satellitePropagator = createSatellitePropagator(THREE, { twoline2satrec, propagate, eciToEcf, gstime });
|
||||
const { computeSatellitePosition, computeSatelliteInertialOrbitPosition, generateFallbackPosition, getOrBuildSatrec } = satellitePropagator;
|
||||
|
||||
let satellitePoints = null;
|
||||
let satelliteBackdropPoints = null;
|
||||
let satelliteTrails = null;
|
||||
@@ -40,10 +50,15 @@ let lockedSatelliteIndex = null;
|
||||
let hoveredSatelliteIndex = null;
|
||||
let positionUpdateAccumulator = 0;
|
||||
let satelliteCapacity = 0;
|
||||
let satelliteSatrecCache = new Map();
|
||||
let satelliteDisplayStyle = DEFAULT_SATELLITE_DISPLAY_STYLE;
|
||||
let satelliteIdleBreathingEnabled = true;
|
||||
let satelliteRealAltitudeEnabled = true;
|
||||
let satellitePositionWorker = null;
|
||||
let satelliteWorkerReady = false;
|
||||
let satelliteWorkerPending = false;
|
||||
let satelliteWorkerDisabled = false;
|
||||
let pendingPositionSnapshot = null;
|
||||
let satelliteWorkerStartupTimer = null;
|
||||
|
||||
const GROUND_FOOTPRINT_RENDER_ORDER = 3;
|
||||
|
||||
@@ -120,14 +135,11 @@ const TRAIL_INSTANCE_ATTRIBUTE_NAMES = [
|
||||
"instanceColorStart",
|
||||
"instanceColorEnd",
|
||||
];
|
||||
const FALLBACK_ORBIT_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const FALLBACK_MIN_MEAN_MOTION = 12;
|
||||
const FALLBACK_MEAN_MOTION_SPREAD = 4;
|
||||
const FALLBACK_TRAIL_TIP_LENGTH = 0.004;
|
||||
const FALLBACK_TRAIL_ALPHA_START = 0.2;
|
||||
const FALLBACK_TRAIL_ALPHA_END = 0.8;
|
||||
const DOT_TEXTURE_SIZE = 32;
|
||||
const POSITION_UPDATE_INTERVAL_MS = 250;
|
||||
const POSITION_UPDATE_INTERVAL_MS = SATELLITE_POSITION_UPDATE_INTERVAL_MS;
|
||||
const FOOTPRINT_DIRECTION_SAMPLE_MS = 30000;
|
||||
const BACKGROUND_TRAIL_RESET_DELTA_MS = 2000;
|
||||
const SATELLITE_TWINKLE_SECONDARY_SPEED = 1.73;
|
||||
@@ -149,7 +161,6 @@ const LOCKED_RING_HOVER_SCALE = 1.32;
|
||||
const LOCKED_RING_HOVER_LINE_WIDTH = 5;
|
||||
const HOVER_RING_LINE_WIDTH = 3;
|
||||
const LOCKED_RING_IDLE_OPACITY = 0.92;
|
||||
const EARTH_RADIUS_KM = 6378.137;
|
||||
const GROUND_FOOTPRINT_MIN_ELEVATION_DEG = 25;
|
||||
const GROUND_FOOTPRINT_RADIUS_OFFSET = 0.72;
|
||||
const GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM = 550;
|
||||
@@ -213,6 +224,8 @@ function createSatellitePointMaterial({
|
||||
size: { value: size * getPointPixelRatio() },
|
||||
opacity: { value: opacity },
|
||||
baseColor: { value: new THREE.Color(baseColor) },
|
||||
breathingPhase: { value: 0 },
|
||||
breathingEnabled: { value: 0 },
|
||||
},
|
||||
transparent: true,
|
||||
depthTest: true,
|
||||
@@ -220,13 +233,26 @@ function createSatellitePointMaterial({
|
||||
vertexShader: `
|
||||
uniform float size;
|
||||
uniform vec3 baseColor;
|
||||
uniform float breathingPhase;
|
||||
uniform float breathingEnabled;
|
||||
attribute float alpha;
|
||||
attribute vec3 twinklePhase;
|
||||
attribute vec2 twinkleShape;
|
||||
${useVertexColor ? "attribute vec3 color;" : ""}
|
||||
varying float vAlpha;
|
||||
varying vec3 vColor;
|
||||
|
||||
void main() {
|
||||
vAlpha = alpha;
|
||||
if (breathingEnabled > 0.5) {
|
||||
float phase = breathingPhase * twinklePhase.z;
|
||||
float primary = 0.5 + 0.5 * sin(phase + twinklePhase.x);
|
||||
float secondary = 0.5 + 0.5 * sin(phase * ${SATELLITE_TWINKLE_SECONDARY_SPEED.toFixed(8)} + twinklePhase.y);
|
||||
float pulse = clamp(mix(primary, secondary, ${SATELLITE_TWINKLE_SECONDARY_WEIGHT.toFixed(8)}), 0.0, 1.0);
|
||||
float shaped = twinkleShape.x + pow(pulse, 1.8) * twinkleShape.y;
|
||||
vAlpha *= clamp(mix(${SATELLITE_CONFIG.dotOpacityMin.toFixed(8)}, ${SATELLITE_CONFIG.dotOpacityMax.toFixed(8)}, shaped),
|
||||
${SATELLITE_CONFIG.dotOpacityMin.toFixed(8)}, ${SATELLITE_CONFIG.dotOpacityMax.toFixed(8)});
|
||||
}
|
||||
vColor = ${useVertexColor ? "color" : "baseColor"};
|
||||
gl_PointSize = size;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
@@ -351,6 +377,7 @@ export function setSatelliteRealAltitudeEnabled(enabled) {
|
||||
}
|
||||
|
||||
satelliteRealAltitudeEnabled = nextEnabled;
|
||||
satellitePropagator.setRealAltitudeEnabled(nextEnabled);
|
||||
clearSatelliteTrails();
|
||||
updateSatellitePositions(0, true, { resetTrails: true });
|
||||
if (
|
||||
@@ -377,21 +404,10 @@ export function updateSatelliteIdleBreathingVisual(isIdle = true) {
|
||||
satelliteBackdropPoints.material.opacity = 0.42;
|
||||
}
|
||||
|
||||
const pointAlphaAttr = satellitePoints.geometry.attributes.alpha;
|
||||
const backdropAlphaAttr = satelliteBackdropPoints.geometry.attributes.alpha;
|
||||
if (!pointAlphaAttr?.array || !backdropAlphaAttr?.array) return;
|
||||
|
||||
const drawCount = satellitePoints.geometry.drawRange?.count ?? satelliteCapacity;
|
||||
const count = Math.min(drawCount, satelliteCapacity, satellitePositions.length);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const alpha = shouldHideSatellitePoint(i)
|
||||
? 0
|
||||
: getSatelliteTwinkleAlpha(i, isIdle);
|
||||
pointAlphaAttr.array[i] = alpha;
|
||||
backdropAlphaAttr.array[i] = alpha;
|
||||
for (const points of [satellitePoints, satelliteBackdropPoints]) {
|
||||
points.material.uniforms.breathingPhase.value = breathingPhase;
|
||||
points.material.uniforms.breathingEnabled.value = satelliteIdleBreathingEnabled && isIdle ? 1 : 0;
|
||||
}
|
||||
pointAlphaAttr.needsUpdate = true;
|
||||
backdropAlphaAttr.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function updateSatellitePointSize() {
|
||||
@@ -722,32 +738,6 @@ function createSatellitePositionState(index = 0) {
|
||||
};
|
||||
}
|
||||
|
||||
function getSatelliteTwinkleAlpha(index, isIdle = true) {
|
||||
if (!satelliteIdleBreathingEnabled || !isIdle) return 1;
|
||||
|
||||
const twinkle = satellitePositions[index]?.twinkle || createSatelliteTwinkleState(index);
|
||||
const primaryPulse = getBreathingPulse(
|
||||
breathingPhase * twinkle.speed + twinkle.phase,
|
||||
);
|
||||
const secondaryPulse = getBreathingPulse(
|
||||
breathingPhase * twinkle.speed * SATELLITE_TWINKLE_SECONDARY_SPEED +
|
||||
twinkle.secondaryPhase,
|
||||
);
|
||||
const mixedPulse = THREE.MathUtils.clamp(
|
||||
primaryPulse * (1 - SATELLITE_TWINKLE_SECONDARY_WEIGHT) +
|
||||
secondaryPulse * SATELLITE_TWINKLE_SECONDARY_WEIGHT,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const shapedPulse = twinkle.floor + Math.pow(mixedPulse, 1.8) * twinkle.intensity;
|
||||
return THREE.MathUtils.clamp(
|
||||
SATELLITE_CONFIG.dotOpacityMin +
|
||||
shapedPulse * (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin),
|
||||
SATELLITE_CONFIG.dotOpacityMin,
|
||||
SATELLITE_CONFIG.dotOpacityMax,
|
||||
);
|
||||
}
|
||||
|
||||
function resetSatelliteTrailState() {
|
||||
satellitePositions.forEach((position) => {
|
||||
position.trail = [];
|
||||
@@ -771,6 +761,61 @@ function clearSatelliteTrailGeometry() {
|
||||
function clearSatelliteTrails() {
|
||||
resetSatelliteTrailState();
|
||||
clearSatelliteTrailGeometry();
|
||||
if (satelliteData.length) startSatellitePositionWorker();
|
||||
}
|
||||
|
||||
function stopSatellitePositionWorker() {
|
||||
clearTimeout(satelliteWorkerStartupTimer);
|
||||
satelliteWorkerStartupTimer = null;
|
||||
satellitePositionWorker?.terminate();
|
||||
satellitePositionWorker = null;
|
||||
satelliteWorkerReady = false;
|
||||
satelliteWorkerPending = false;
|
||||
pendingPositionSnapshot = null;
|
||||
}
|
||||
|
||||
function startSatellitePositionWorker() {
|
||||
stopSatellitePositionWorker();
|
||||
if (satelliteWorkerDisabled || !satelliteData.length || typeof Worker === "undefined") return;
|
||||
const fail = (error) => {
|
||||
console.warn("Satellite position worker unavailable; using synchronous propagation", error?.message || error);
|
||||
satelliteWorkerDisabled = true;
|
||||
stopSatellitePositionWorker();
|
||||
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
|
||||
};
|
||||
try {
|
||||
const worker = new Worker(new URL("./satellite-position-worker.js", import.meta.url), { type: "module" });
|
||||
satellitePositionWorker = worker;
|
||||
worker.onmessage = ({ data }) => {
|
||||
if (worker !== satellitePositionWorker) return;
|
||||
if (data.type === "ready") {
|
||||
clearTimeout(satelliteWorkerStartupTimer);
|
||||
satelliteWorkerStartupTimer = null;
|
||||
satelliteWorkerReady = true;
|
||||
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
|
||||
} else if (data.type === "positions") {
|
||||
satelliteWorkerPending = false;
|
||||
pendingPositionSnapshot = data;
|
||||
} else if (data.type === "error") {
|
||||
fail(data);
|
||||
}
|
||||
};
|
||||
worker.onerror = (error) => {
|
||||
if (worker === satellitePositionWorker) fail(error);
|
||||
};
|
||||
satelliteWorkerStartupTimer = setTimeout(() => {
|
||||
if (worker === satellitePositionWorker) fail(new Error("Worker startup timed out"));
|
||||
}, SATELLITE_CONFIG.workerStartupTimeoutMs);
|
||||
worker.postMessage({
|
||||
type: "init",
|
||||
satellites: satelliteData,
|
||||
realAltitude: satelliteRealAltitudeEnabled,
|
||||
threeUrl: import.meta.resolve("three"),
|
||||
satelliteUrl: import.meta.resolve("satellite.js"),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSatelliteCapacity(count) {
|
||||
@@ -917,6 +962,23 @@ function ensureSatelliteCapacity(count) {
|
||||
twinkle: previousState.twinkle || createSatelliteTwinkleState(index),
|
||||
};
|
||||
});
|
||||
const twinklePhases = new Float32Array(nextCapacity * 3);
|
||||
const twinkleShapes = new Float32Array(nextCapacity * 2);
|
||||
satellitePositions.forEach(({ twinkle }, index) => {
|
||||
twinklePhases.set([twinkle.phase, twinkle.secondaryPhase, twinkle.speed], index * 3);
|
||||
twinkleShapes.set([twinkle.floor, twinkle.intensity], index * 2);
|
||||
});
|
||||
const phaseAttribute = new THREE.BufferAttribute(twinklePhases, 3);
|
||||
const shapeAttribute = new THREE.BufferAttribute(twinkleShapes, 2);
|
||||
for (const points of [satellitePoints, satelliteBackdropPoints]) {
|
||||
points.geometry.setAttribute("twinklePhase", phaseAttribute);
|
||||
points.geometry.setAttribute("twinkleShape", shapeAttribute);
|
||||
points.geometry.attributes.position.setUsage(THREE.DynamicDrawUsage);
|
||||
points.geometry.attributes.alpha.setUsage(THREE.DynamicDrawUsage);
|
||||
}
|
||||
for (const name of TRAIL_INSTANCE_ATTRIBUTE_NAMES) {
|
||||
satelliteTrails.geometry.attributes[name].setUsage(THREE.DynamicDrawUsage);
|
||||
}
|
||||
satelliteCapacity = nextCapacity;
|
||||
}
|
||||
|
||||
@@ -939,295 +1001,6 @@ function updateSatellitePointVisibilityAttributes(count = satelliteData.length)
|
||||
backdropAlphaAttr.needsUpdate = true;
|
||||
}
|
||||
|
||||
function computeSatellitePosition(satellite, time) {
|
||||
try {
|
||||
const props = satellite.properties;
|
||||
if (!props || !props.norad_cat_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const satrec = getOrBuildSatrec(props, time);
|
||||
if (!satrec || satrec.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const positionAndVelocity = propagate(satrec, time);
|
||||
if (!positionAndVelocity || !positionAndVelocity.position) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return computeDisplayPositionFromEciPosition(
|
||||
positionAndVelocity.position,
|
||||
gstime(time),
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function computeDisplayPositionFromEciPosition(positionEci, siderealTime) {
|
||||
const earthFixedPosition = convertEciPositionToSceneVector(
|
||||
positionEci,
|
||||
siderealTime,
|
||||
);
|
||||
const x = earthFixedPosition.x;
|
||||
const y = earthFixedPosition.y;
|
||||
const z = earthFixedPosition.z;
|
||||
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const r = Math.sqrt(
|
||||
positionEci.x * positionEci.x +
|
||||
positionEci.y * positionEci.y +
|
||||
positionEci.z * positionEci.z,
|
||||
);
|
||||
if (!Number.isFinite(r) || r <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayRadius = satelliteRealAltitudeEnabled
|
||||
? CONFIG.earthRadius + getCompressedRealAltitudeOffset(r)
|
||||
: CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
|
||||
const sceneRadius = Math.sqrt(x * x + y * y + z * z);
|
||||
if (!Number.isFinite(sceneRadius) || sceneRadius <= 0) {
|
||||
return null;
|
||||
}
|
||||
const scale = displayRadius / sceneRadius;
|
||||
|
||||
return new THREE.Vector3(x * scale, y * scale, z * scale);
|
||||
}
|
||||
|
||||
function computeSatelliteInertialOrbitPosition(satellite, time, siderealTime) {
|
||||
try {
|
||||
const props = satellite.properties;
|
||||
if (!props || !props.norad_cat_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const satrec = getOrBuildSatrec(props, time);
|
||||
if (!satrec || satrec.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const positionAndVelocity = propagate(satrec, time);
|
||||
if (!positionAndVelocity || !positionAndVelocity.position) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return computeDisplayPositionFromEciPosition(
|
||||
positionAndVelocity.position,
|
||||
siderealTime,
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function convertEciPositionToSceneVector(positionEci, siderealTime) {
|
||||
const positionEcf = eciToEcf(positionEci, siderealTime);
|
||||
return new THREE.Vector3(
|
||||
positionEcf.x,
|
||||
positionEcf.z,
|
||||
-positionEcf.y,
|
||||
);
|
||||
}
|
||||
|
||||
function getCompressedRealAltitudeOffset(radiusKm) {
|
||||
const altitudeKm = Math.max(0, radiusKm - EARTH_RADIUS_KM);
|
||||
const clampedAltitudeKm = Math.min(
|
||||
altitudeKm,
|
||||
SATELLITE_CONFIG.maxDisplayAltitudeKm,
|
||||
);
|
||||
const compressionKm = Math.max(1, SATELLITE_CONFIG.altitudeCompressionKm);
|
||||
const normalizedAltitude = Math.log1p(clampedAltitudeKm / compressionKm) /
|
||||
Math.log1p(SATELLITE_CONFIG.maxDisplayAltitudeKm / compressionKm);
|
||||
|
||||
return THREE.MathUtils.lerp(
|
||||
SATELLITE_CONFIG.minRealAltitudeOffset,
|
||||
SATELLITE_CONFIG.maxRealAltitudeOffset,
|
||||
THREE.MathUtils.clamp(normalizedAltitude, 0, 1),
|
||||
);
|
||||
}
|
||||
|
||||
function buildSatrecFromProperties(props, fallbackTime) {
|
||||
if (props.tle_line1 && props.tle_line2) {
|
||||
// Prefer source-provided TLE lines so the client does not need to rebuild them.
|
||||
const satrec = twoline2satrec(props.tle_line1, props.tle_line2);
|
||||
if (!satrec.error) {
|
||||
return satrec;
|
||||
}
|
||||
}
|
||||
|
||||
const tleLines = buildTleLinesFromElements(props, fallbackTime);
|
||||
if (!tleLines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return twoline2satrec(tleLines.line1, tleLines.line2);
|
||||
}
|
||||
|
||||
function getSatelliteSatrecCacheKey(props) {
|
||||
if (!props?.norad_cat_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (props.tle_line1 && props.tle_line2) {
|
||||
return `tle:${props.norad_cat_id}:${props.tle_line1}:${props.tle_line2}`;
|
||||
}
|
||||
|
||||
if (props.epoch) {
|
||||
return [
|
||||
"elements",
|
||||
props.norad_cat_id,
|
||||
props.epoch,
|
||||
props.inclination,
|
||||
props.raan,
|
||||
props.eccentricity,
|
||||
props.arg_of_perigee,
|
||||
props.mean_anomaly,
|
||||
props.mean_motion,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOrBuildSatrec(props, fallbackTime) {
|
||||
const cacheKey = getSatelliteSatrecCacheKey(props);
|
||||
if (cacheKey && satelliteSatrecCache.has(cacheKey)) {
|
||||
return satelliteSatrecCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const satrec = buildSatrecFromProperties(props, fallbackTime);
|
||||
if (cacheKey && satrec && !satrec.error) {
|
||||
satelliteSatrecCache.set(cacheKey, satrec);
|
||||
}
|
||||
return satrec;
|
||||
}
|
||||
|
||||
function computeTleChecksum(line) {
|
||||
let sum = 0;
|
||||
|
||||
for (const char of line.slice(0, 68)) {
|
||||
if (char >= "0" && char <= "9") {
|
||||
sum += Number(char);
|
||||
} else if (char === "-") {
|
||||
sum += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return String(sum % 10);
|
||||
}
|
||||
|
||||
function buildTleLinesFromElements(props, fallbackTime) {
|
||||
if (!props?.norad_cat_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requiredValues = [
|
||||
props.inclination,
|
||||
props.raan,
|
||||
props.eccentricity,
|
||||
props.arg_of_perigee,
|
||||
props.mean_anomaly,
|
||||
props.mean_motion,
|
||||
];
|
||||
if (requiredValues.some((value) => value === null || value === undefined)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const epochDate =
|
||||
props.epoch && String(props.epoch).length >= 10
|
||||
? new Date(props.epoch)
|
||||
: fallbackTime;
|
||||
if (Number.isNaN(epochDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const epochYear = epochDate.getUTCFullYear() % 100;
|
||||
const startOfYear = new Date(Date.UTC(epochDate.getUTCFullYear(), 0, 1));
|
||||
const dayOfYear = Math.floor((epochDate - startOfYear) / 86400000) + 1;
|
||||
const msOfDay =
|
||||
epochDate.getUTCHours() * 3600000 +
|
||||
epochDate.getUTCMinutes() * 60000 +
|
||||
epochDate.getUTCSeconds() * 1000 +
|
||||
epochDate.getUTCMilliseconds();
|
||||
const dayFraction = msOfDay / 86400000;
|
||||
const epochStr =
|
||||
String(epochYear).padStart(2, "0") +
|
||||
String(dayOfYear).padStart(3, "0") +
|
||||
dayFraction.toFixed(8).slice(1);
|
||||
|
||||
const eccentricityDigits = Math.round(Number(props.eccentricity) * 1e7)
|
||||
.toString()
|
||||
.padStart(7, "0");
|
||||
|
||||
// Keep a local fallback for historical rows that do not have stored TLE lines yet.
|
||||
const line1Core = `1 ${String(props.norad_cat_id).padStart(5, "0")}U 00001A ${epochStr} .00000000 00000-0 00000-0 0 999`;
|
||||
const line2Core = `2 ${String(props.norad_cat_id).padStart(5, "0")} ${Number(
|
||||
props.inclination,
|
||||
)
|
||||
.toFixed(4)
|
||||
.padStart(
|
||||
8,
|
||||
)} ${Number(props.raan).toFixed(4).padStart(8)} ${eccentricityDigits} ${Number(
|
||||
props.arg_of_perigee,
|
||||
)
|
||||
.toFixed(4)
|
||||
.padStart(8)} ${Number(props.mean_anomaly).toFixed(4).padStart(8)} ${Number(
|
||||
props.mean_motion,
|
||||
)
|
||||
.toFixed(8)
|
||||
.padStart(11)}00000`;
|
||||
|
||||
return {
|
||||
line1: line1Core + computeTleChecksum(line1Core),
|
||||
line2: line2Core + computeTleChecksum(line2Core),
|
||||
};
|
||||
}
|
||||
|
||||
function generateFallbackPosition(satellite, index, total, time = new Date()) {
|
||||
const radius = CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
|
||||
|
||||
const noradId = satellite.properties?.norad_cat_id || index;
|
||||
const inclination = satellite.properties?.inclination || 53;
|
||||
const raan = satellite.properties?.raan || 0;
|
||||
const meanAnomaly = satellite.properties?.mean_anomaly || 0;
|
||||
|
||||
const hash = String(noradId)
|
||||
.split("")
|
||||
.reduce((a, b) => a + b.charCodeAt(0), 0);
|
||||
const randomOffset = (hash % 1000) / 1000;
|
||||
const rawMeanMotion = Number(satellite.properties?.mean_motion);
|
||||
const meanMotion =
|
||||
Number.isFinite(rawMeanMotion) && rawMeanMotion > 0
|
||||
? rawMeanMotion
|
||||
: FALLBACK_MIN_MEAN_MOTION + randomOffset * FALLBACK_MEAN_MOTION_SPREAD;
|
||||
|
||||
const normalizedIndex = index / total;
|
||||
const elapsedDays = Number.isFinite(time?.getTime?.())
|
||||
? time.getTime() / FALLBACK_ORBIT_DAY_MS
|
||||
: Date.now() / FALLBACK_ORBIT_DAY_MS;
|
||||
const fallbackPhase = elapsedDays * meanMotion * Math.PI * 2;
|
||||
const theta =
|
||||
normalizedIndex * Math.PI * 2 * 10 +
|
||||
(raan * Math.PI) / 180 +
|
||||
fallbackPhase;
|
||||
const phi =
|
||||
(inclination * Math.PI) / 180 + ((meanAnomaly * Math.PI) / 180) * 0.1;
|
||||
|
||||
const adjustedPhi = Math.abs(phi % Math.PI);
|
||||
const adjustedTheta = theta + randomOffset * Math.PI * 2;
|
||||
|
||||
const x = radius * Math.sin(adjustedPhi) * Math.cos(adjustedTheta);
|
||||
const y = radius * Math.cos(adjustedPhi);
|
||||
const z = radius * Math.sin(adjustedPhi) * Math.sin(adjustedTheta);
|
||||
|
||||
return new THREE.Vector3(x, y, z);
|
||||
}
|
||||
|
||||
export async function loadSatellites(options = {}) {
|
||||
const limit = getRequestedSatelliteLimit(options.limit);
|
||||
const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin);
|
||||
@@ -1247,9 +1020,11 @@ export async function loadSatellites(options = {}) {
|
||||
|
||||
const data = await response.json();
|
||||
satelliteData = data.features || [];
|
||||
satelliteSatrecCache = new Map();
|
||||
satellitePropagator.reset();
|
||||
resetSatelliteTrailState();
|
||||
ensureSatelliteCapacity(satelliteData.length);
|
||||
satelliteWorkerDisabled = false;
|
||||
startSatellitePositionWorker();
|
||||
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
|
||||
return {
|
||||
count: satelliteData.length,
|
||||
@@ -1264,6 +1039,11 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
|
||||
showSatellites ||
|
||||
showTrails ||
|
||||
lockedSatelliteIndex !== null;
|
||||
if (pendingPositionSnapshot) {
|
||||
const snapshot = pendingPositionSnapshot;
|
||||
pendingPositionSnapshot = null;
|
||||
applySatellitePositionSnapshot(snapshot, shouldUpdateTrails);
|
||||
}
|
||||
const shouldResetTrails =
|
||||
options.resetTrails ||
|
||||
(!force && deltaTime >= BACKGROUND_TRAIL_RESET_DELTA_MS);
|
||||
@@ -1287,6 +1067,20 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
|
||||
);
|
||||
positionUpdateAccumulator = 0;
|
||||
|
||||
if (satellitePositionWorker) {
|
||||
if (!satelliteWorkerReady || satelliteWorkerPending) return;
|
||||
satelliteWorkerPending = true;
|
||||
satellitePositionWorker.postMessage({
|
||||
type: "update",
|
||||
baseTime: Date.now() + elapsedMs,
|
||||
seedTrails: shouldUpdateTrails && satellitePositions.some((position) => position.trailCount === 0),
|
||||
});
|
||||
return;
|
||||
}
|
||||
applySatellitePositionSnapshot({ baseTime: Date.now() + elapsedMs }, shouldUpdateTrails);
|
||||
}
|
||||
|
||||
function applySatellitePositionSnapshot(snapshot, shouldUpdateTrails) {
|
||||
const positions = satellitePoints.geometry.attributes.position.array;
|
||||
const backdropPositions =
|
||||
satelliteBackdropPoints.geometry.attributes.position.array;
|
||||
@@ -1297,19 +1091,17 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
|
||||
const instanceEnds = satelliteTrails.geometry.attributes.instanceEnd.array;
|
||||
const instanceColorStarts = satelliteTrails.geometry.attributes.instanceColorStart.array;
|
||||
const instanceColorEnds = satelliteTrails.geometry.attributes.instanceColorEnd.array;
|
||||
const baseTime = new Date(Date.now() + elapsedMs);
|
||||
const baseTime = new Date(snapshot.baseTime);
|
||||
const count = Math.min(satelliteData.length, satelliteCapacity);
|
||||
let trailSegmentCount = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const satellite = satelliteData[i];
|
||||
const props = satellite.properties;
|
||||
const timeOffset = (i / count) * 2 * Math.PI * 0.1;
|
||||
const adjustedTime = new Date(
|
||||
baseTime.getTime() + timeOffset * 1000 * 60 * 10,
|
||||
);
|
||||
|
||||
let pos = computeSatellitePosition(satellite, adjustedTime);
|
||||
const adjustedTime = new Date(getSatelliteSampleTimeMs(baseTime.getTime(), i, count));
|
||||
let pos = snapshot.positions
|
||||
? satellitePositions[i].current.fromArray(snapshot.positions, i * 3)
|
||||
: computeSatellitePosition(satellite, adjustedTime);
|
||||
if (!pos) {
|
||||
pos = generateFallbackPosition(satellite, i, count, adjustedTime);
|
||||
}
|
||||
@@ -1323,7 +1115,10 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
|
||||
for (let k = 0; k < TRAIL_LENGTH; k++) {
|
||||
const offsetMs = (TRAIL_LENGTH - 1 - k) * POSITION_UPDATE_INTERVAL_MS;
|
||||
const pastTime = new Date(adjustedTime.getTime() - offsetMs);
|
||||
let pastPos = computeSatellitePosition(satellite, pastTime);
|
||||
let pastPos = snapshot.trailSeed
|
||||
? (satPos.trail[satPos.trailIndex] || new THREE.Vector3())
|
||||
.fromArray(snapshot.trailSeed, (i * TRAIL_LENGTH + k) * 3)
|
||||
: computeSatellitePosition(satellite, pastTime);
|
||||
if (!pastPos) {
|
||||
pastPos = generateFallbackPosition(satellite, i, count, pastTime);
|
||||
}
|
||||
@@ -1332,7 +1127,8 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
|
||||
}
|
||||
satPos.trailCount = TRAIL_LENGTH;
|
||||
} else {
|
||||
satPos.trail[satPos.trailIndex] = pos.clone();
|
||||
const trailPosition = satPos.trail[satPos.trailIndex] || new THREE.Vector3();
|
||||
satPos.trail[satPos.trailIndex] = trailPosition.copy(pos);
|
||||
satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH;
|
||||
if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++;
|
||||
}
|
||||
@@ -2781,8 +2577,9 @@ export function hidePredictedOrbit() {
|
||||
}
|
||||
|
||||
export function clearSatelliteData() {
|
||||
stopSatellitePositionWorker();
|
||||
satelliteData = [];
|
||||
satelliteSatrecCache = new Map();
|
||||
satellitePropagator.reset();
|
||||
selectedSatellite = null;
|
||||
lockedSatelliteIndex = null;
|
||||
hoveredSatelliteIndex = null;
|
||||
@@ -2857,7 +2654,7 @@ export function resetSatelliteState() {
|
||||
|
||||
satellitePositions = [];
|
||||
satelliteCapacity = 0;
|
||||
satelliteSatrecCache = new Map();
|
||||
satellitePropagator.reset();
|
||||
showSatellites = false;
|
||||
showTrails = true;
|
||||
}
|
||||
|
||||
317
frontend/public/earth/js/tv-source-menu.js
Normal file
317
frontend/public/earth/js/tv-source-menu.js
Normal file
@@ -0,0 +1,317 @@
|
||||
import { earthMessage, formatEarthMessage, translateText } from "./i18n.js";
|
||||
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
const MENU_GAP = 6;
|
||||
const MENU_EXTRA_WIDTH = 48;
|
||||
const MENU_MAX_HEIGHT = 420;
|
||||
const MENU_MIN_HEIGHT_BELOW = 240;
|
||||
const SEARCH_DELAY_MS = 250;
|
||||
const LOAD_MORE_THRESHOLD = 24;
|
||||
|
||||
function createIcon(name, className = "") {
|
||||
const icon = document.createElement("span");
|
||||
icon.className = `material-symbols-rounded ${className}`;
|
||||
icon.textContent = name;
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function createTVSourceMenu({ triggers, onSelect, loadPage, formatSource }) {
|
||||
const menu = document.createElement("div");
|
||||
menu.id = "tv-source-menu";
|
||||
menu.className = "hud-panel tv-source-menu";
|
||||
menu.setAttribute("popover", "auto");
|
||||
menu.setAttribute("role", "dialog");
|
||||
|
||||
const searchShell = document.createElement("div");
|
||||
searchShell.className = "tv-source-menu__search";
|
||||
const search = document.createElement("input");
|
||||
search.id = "tv-source-search";
|
||||
search.type = "search";
|
||||
search.autocomplete = "off";
|
||||
search.spellcheck = false;
|
||||
search.setAttribute("role", "combobox");
|
||||
search.setAttribute("aria-autocomplete", "list");
|
||||
search.setAttribute("aria-controls", "tv-source-options");
|
||||
searchShell.append(createIcon("search"), search);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.id = "tv-source-options";
|
||||
list.className = "legend-list tv-source-menu__list";
|
||||
list.setAttribute("role", "listbox");
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "tv-source-menu__empty";
|
||||
const count = document.createElement("div");
|
||||
count.className = "tv-source-menu__count";
|
||||
count.setAttribute("role", "status");
|
||||
count.setAttribute("aria-live", "polite");
|
||||
const more = document.createElement("button");
|
||||
more.type = "button";
|
||||
more.className = "tv-source-menu__more";
|
||||
const body = document.createElement("div");
|
||||
body.className = "tv-source-menu__body";
|
||||
body.append(list, empty, more);
|
||||
menu.append(searchShell, body, count);
|
||||
document.body.append(menu);
|
||||
|
||||
let sources = [];
|
||||
let filtered = [];
|
||||
let selectedId = "";
|
||||
let activeIndex = -1;
|
||||
let activeTrigger = null;
|
||||
let initialPayload = null;
|
||||
let total = 0;
|
||||
let nextOffset = null;
|
||||
let loading = false;
|
||||
let failed = false;
|
||||
let controller = null;
|
||||
let searchTimer = null;
|
||||
let generation = 0;
|
||||
|
||||
function isOpen() {
|
||||
return menu.matches(":popover-open");
|
||||
}
|
||||
|
||||
function close({ restoreFocus = false } = {}) {
|
||||
if (isOpen()) menu.hidePopover();
|
||||
if (restoreFocus) activeTrigger?.focus();
|
||||
}
|
||||
|
||||
function position() {
|
||||
if (!isOpen() || !activeTrigger) return;
|
||||
const rect = activeTrigger.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
const viewport = window.visualViewport;
|
||||
const viewportTop = viewport?.offsetTop || 0;
|
||||
const viewportLeft = viewport?.offsetLeft || 0;
|
||||
const viewportWidth = viewport?.width || window.innerWidth;
|
||||
const viewportHeight = viewport?.height || window.innerHeight;
|
||||
const left = Math.max(viewportLeft + VIEWPORT_MARGIN, rect.left);
|
||||
const width = Math.min(rect.width + MENU_EXTRA_WIDTH, viewportLeft + viewportWidth - VIEWPORT_MARGIN - left);
|
||||
const below = viewportTop + viewportHeight - rect.bottom - VIEWPORT_MARGIN - MENU_GAP;
|
||||
const above = rect.top - viewportTop - VIEWPORT_MARGIN - MENU_GAP;
|
||||
const openBelow = below >= Math.min(MENU_MAX_HEIGHT, MENU_MIN_HEIGHT_BELOW) || below >= above;
|
||||
menu.style.width = `${width}px`;
|
||||
menu.style.maxHeight = `${Math.max(0, Math.min(MENU_MAX_HEIGHT, openBelow ? below : above))}px`;
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${openBelow ? rect.bottom + MENU_GAP : rect.top - MENU_GAP - menu.getBoundingClientRect().height}px`;
|
||||
}
|
||||
|
||||
function setActive(index, { scroll = false } = {}) {
|
||||
list.children[activeIndex]?.classList.remove("is-active");
|
||||
activeIndex = index;
|
||||
const item = list.children[activeIndex];
|
||||
if (item) {
|
||||
item.classList.add("is-active");
|
||||
search.setAttribute("aria-activedescendant", item.id);
|
||||
if (scroll) item.scrollIntoView({ block: "nearest" });
|
||||
} else {
|
||||
search.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
}
|
||||
|
||||
function choose(index) {
|
||||
const option = filtered[index];
|
||||
if (!option) return;
|
||||
close({ restoreFocus: true });
|
||||
onSelect(option.id);
|
||||
}
|
||||
|
||||
function createOption(option, index) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.id = `tv-source-option-${index}`;
|
||||
row.className = "legend-item tv-source-menu__option";
|
||||
row.tabIndex = -1;
|
||||
row.setAttribute("role", "option");
|
||||
row.setAttribute("aria-selected", String(option.id === selectedId));
|
||||
row.dataset.sourceId = option.id;
|
||||
row.title = option.label;
|
||||
const label = document.createElement("span");
|
||||
label.className = "legend-label";
|
||||
label.textContent = option.label;
|
||||
row.append(label);
|
||||
if (option.isDefault) {
|
||||
const mark = document.createElement("span");
|
||||
mark.className = "tv-source-menu__default";
|
||||
mark.textContent = translateText("默认");
|
||||
row.append(mark);
|
||||
}
|
||||
if (option.failed) row.append(createIcon("warning", "tv-source-menu__warning"));
|
||||
row.append(createIcon("check", "tv-source-menu__check"));
|
||||
row.addEventListener("pointermove", () => setActive(index));
|
||||
row.addEventListener("mousedown", (event) => event.preventDefault());
|
||||
row.addEventListener("click", () => choose(index));
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderOptions({ resetScroll = false } = {}) {
|
||||
const scrollTop = resetScroll ? 0 : list.scrollTop;
|
||||
const activeId = resetScroll ? selectedId : filtered[activeIndex]?.id || selectedId;
|
||||
filtered = sources.map(formatSource);
|
||||
list.replaceChildren(...filtered.map(createOption));
|
||||
empty.hidden = filtered.length > 0 || loading || failed;
|
||||
empty.textContent = translateText(search.value.trim() ? "无匹配频道" : "暂无可用频道");
|
||||
count.textContent = formatEarthMessage(earthMessage("tv.channelResults", {
|
||||
count: filtered.length,
|
||||
total,
|
||||
}));
|
||||
more.hidden = !loading && !failed;
|
||||
more.disabled = loading;
|
||||
more.textContent = translateText(failed ? "加载频道失败,点击重试" : "正在加载频道…");
|
||||
list.setAttribute("aria-busy", String(loading));
|
||||
activeIndex = -1;
|
||||
const selectedIndex = filtered.findIndex((option) => option.id === activeId);
|
||||
setActive(selectedIndex >= 0 ? selectedIndex : filtered.length ? 0 : -1);
|
||||
list.scrollTop = scrollTop;
|
||||
position();
|
||||
}
|
||||
|
||||
function applyPage(payload, append = false) {
|
||||
const incoming = payload?.sources || [];
|
||||
sources = append ? [...sources, ...incoming] : incoming;
|
||||
total = payload?.total ?? payload?.source_count ?? sources.length;
|
||||
nextOffset = payload?.has_more ? payload.next_offset : null;
|
||||
}
|
||||
|
||||
function cancelRequest() {
|
||||
generation += 1;
|
||||
clearTimeout(searchTimer);
|
||||
controller?.abort();
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function requestPage(append = false) {
|
||||
if (append && (loading || nextOffset === null)) return;
|
||||
if (!append) cancelRequest();
|
||||
const token = generation;
|
||||
controller = new AbortController();
|
||||
loading = true;
|
||||
failed = false;
|
||||
renderOptions();
|
||||
try {
|
||||
const payload = await loadPage({
|
||||
q: search.value.trim(), offset: append ? nextOffset : 0, signal: controller.signal,
|
||||
});
|
||||
if (token !== generation) return;
|
||||
applyPage(payload, append);
|
||||
} catch (error) {
|
||||
if (token !== generation || error.name === "AbortError") return;
|
||||
failed = true;
|
||||
} finally {
|
||||
if (token === generation) {
|
||||
loading = false;
|
||||
renderOptions({ resetScroll: !append });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function update({ payload, currentId, selectedSource }) {
|
||||
selectedId = currentId;
|
||||
if (payload !== initialPayload) {
|
||||
initialPayload = payload;
|
||||
cancelRequest();
|
||||
search.value = "";
|
||||
applyPage(payload);
|
||||
failed = false;
|
||||
}
|
||||
const selected = selectedSource ? formatSource(selectedSource) : null;
|
||||
for (const trigger of triggers) {
|
||||
const label = document.createElement("span");
|
||||
label.className = "tv-source-trigger__label";
|
||||
label.textContent = `${selected?.label || translateText("暂无可用频道")}${selected?.isDefault ? ` · ${translateText("默认")}` : ""}`;
|
||||
trigger.replaceChildren(label, createIcon("expand_more"));
|
||||
trigger.dataset.i18nOriginalTitle = label.textContent;
|
||||
trigger.title = label.textContent;
|
||||
trigger.disabled = !initialPayload;
|
||||
}
|
||||
menu.setAttribute("aria-label", translateText("选择新闻直播源"));
|
||||
list.setAttribute("aria-label", translateText("频道列表"));
|
||||
search.placeholder = translateText("搜索频道、地区…");
|
||||
search.setAttribute("aria-label", translateText("搜索频道、地区…"));
|
||||
renderOptions();
|
||||
}
|
||||
|
||||
function open(trigger, last = false) {
|
||||
if (!initialPayload) return;
|
||||
if (isOpen()) close();
|
||||
cancelRequest();
|
||||
activeTrigger = trigger;
|
||||
search.value = "";
|
||||
applyPage(initialPayload);
|
||||
failed = false;
|
||||
renderOptions({ resetScroll: true });
|
||||
menu.showPopover();
|
||||
trigger.setAttribute("aria-expanded", "true");
|
||||
search.setAttribute("aria-expanded", "true");
|
||||
position();
|
||||
search.focus({ preventScroll: true });
|
||||
if (last) setActive(filtered.length - 1, { scroll: true });
|
||||
else setActive(activeIndex, { scroll: true });
|
||||
}
|
||||
|
||||
for (const trigger of triggers) {
|
||||
trigger.setAttribute("aria-controls", menu.id);
|
||||
trigger.addEventListener("click", () => {
|
||||
if (isOpen() && activeTrigger === trigger) close();
|
||||
else open(trigger);
|
||||
});
|
||||
trigger.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||
event.preventDefault();
|
||||
open(trigger, event.key === "ArrowUp");
|
||||
});
|
||||
}
|
||||
search.addEventListener("input", () => {
|
||||
cancelRequest();
|
||||
sources = [];
|
||||
total = 0;
|
||||
nextOffset = null;
|
||||
failed = false;
|
||||
loading = true;
|
||||
renderOptions({ resetScroll: true });
|
||||
searchTimer = setTimeout(() => requestPage(), SEARCH_DELAY_MS);
|
||||
});
|
||||
list.addEventListener("scroll", () => {
|
||||
if (!failed && list.scrollHeight - list.scrollTop - list.clientHeight < LOAD_MORE_THRESHOLD) {
|
||||
requestPage(true);
|
||||
}
|
||||
});
|
||||
more.addEventListener("click", () => requestPage(sources.length > 0));
|
||||
menu.addEventListener("keydown", (event) => {
|
||||
event.stopPropagation();
|
||||
if (event.isComposing) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close({ restoreFocus: true });
|
||||
} else if (event.key === "Tab") {
|
||||
close({ restoreFocus: true });
|
||||
} else if (event.key === "Enter") {
|
||||
if (event.target === more) return;
|
||||
event.preventDefault();
|
||||
if (failed) requestPage(sources.length > 0);
|
||||
else choose(activeIndex);
|
||||
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (filtered.length) {
|
||||
const step = event.key === "ArrowDown" ? 1 : -1;
|
||||
setActive((activeIndex + step + filtered.length) % filtered.length, { scroll: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
menu.addEventListener("toggle", () => {
|
||||
if (isOpen()) return;
|
||||
cancelRequest();
|
||||
triggers.forEach((trigger) => trigger.setAttribute("aria-expanded", "false"));
|
||||
search.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
window.addEventListener("resize", close);
|
||||
window.visualViewport?.addEventListener("resize", position);
|
||||
document.addEventListener("scroll", (event) => {
|
||||
if (isOpen() && !menu.contains(event.target)) position();
|
||||
}, true);
|
||||
return { update, close };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Hls from "hls.js";
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import { createHUDPanel } from "./hud-panels.js";
|
||||
import { createTVSourceMenu } from "./tv-source-menu.js";
|
||||
import {
|
||||
earthMessage,
|
||||
formatLocaleDateTime,
|
||||
@@ -38,6 +39,9 @@ let mediaPanel = null;
|
||||
const failedSourceIds = new Set();
|
||||
let probeTimer = null;
|
||||
let mobileMetaCollapsed = false;
|
||||
let sourceMenu = null;
|
||||
const sourceCache = new Map();
|
||||
const TV_SOURCE_PAGE_SIZE = 50;
|
||||
|
||||
const META_AUTO_COLLAPSE_DELAY = 2500;
|
||||
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
|
||||
@@ -185,7 +189,6 @@ function getElements() {
|
||||
// Outer media shell node.
|
||||
panel: document.getElementById("media-panel"),
|
||||
toggleBtn: document.getElementById("toggle-tv"),
|
||||
select: document.getElementById(isMobile ? "mobile-tv-source-select" : "tv-source-select"),
|
||||
title: document.getElementById(isMobile ? "mobile-tv-source-title" : "tv-source-title"),
|
||||
meta: document.getElementById(isMobile ? "mobile-tv-source-meta" : "tv-source-meta"),
|
||||
catalog: document.getElementById(isMobile ? "mobile-tv-source-catalog" : "tv-source-catalog"),
|
||||
@@ -725,7 +728,7 @@ function syncMobileOverviewSummary(source) {
|
||||
}
|
||||
|
||||
function findSourceById(sourceId) {
|
||||
return tvPayload?.sources?.find((source) => source.id === sourceId) || null;
|
||||
return sourceCache.get(sourceId) || null;
|
||||
}
|
||||
|
||||
function markSourceFailed(sourceId) {
|
||||
@@ -775,8 +778,7 @@ function tryFallbackSource() {
|
||||
if (!fallback || fallback.id === currentSourceId) return false;
|
||||
markSourceFailed(currentSourceId);
|
||||
currentSourceId = fallback.id;
|
||||
const { select } = getElements();
|
||||
if (select) select.value = currentSourceId;
|
||||
renderSourceOptions();
|
||||
renderSource(fallback);
|
||||
return true;
|
||||
}
|
||||
@@ -852,24 +854,11 @@ function renderEmbeddedSource(iframe, video, embeddedUrl) {
|
||||
}
|
||||
|
||||
function renderSourceOptions() {
|
||||
const { select } = getElements();
|
||||
if (!select) return;
|
||||
const sources = tvPayload?.sources || [];
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
sources.forEach((source) => {
|
||||
const defaultMark = source.id === tvPayload?.default_source_id ? ` · ${translateText("默认")}` : "";
|
||||
const failMark = failedSourceIds.has(source.id) ? " ⚠" : "";
|
||||
const option = document.createElement("option");
|
||||
option.value = source.id;
|
||||
option.textContent = `${getTVSourceName(source)}${defaultMark}${failMark}`;
|
||||
fragment.appendChild(option);
|
||||
sourceMenu?.update({
|
||||
payload: tvPayload,
|
||||
currentId: currentSourceId,
|
||||
selectedSource: findSourceById(currentSourceId),
|
||||
});
|
||||
|
||||
select.replaceChildren(fragment);
|
||||
if (currentSourceId) {
|
||||
select.value = currentSourceId;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSource(source) {
|
||||
@@ -956,16 +945,32 @@ function renderPanel() {
|
||||
renderSource(findSourceById(currentSourceId));
|
||||
}
|
||||
|
||||
export async function loadTVStreams() {
|
||||
const response = await fetch(TV_STREAMS_API, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
function cacheTVSources(payload) {
|
||||
for (const source of [...(payload.sources || []), payload.selected_source, payload.fallback_source]) {
|
||||
if (source?.id) sourceCache.set(source.id, source);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTVSourcePage({ q = "", offset = 0, signal, selectedId = "" } = {}) {
|
||||
const params = new URLSearchParams({ limit: String(TV_SOURCE_PAGE_SIZE), offset: String(offset) });
|
||||
if (q) params.set("q", q);
|
||||
if (selectedId) params.set("selected_id", selectedId);
|
||||
const response = await fetch(`${TV_STREAMS_API}?${params}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load TV streams: ${response.status}`);
|
||||
}
|
||||
tvPayload = await response.json();
|
||||
const payload = await response.json();
|
||||
cacheTVSources(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function loadTVStreams() {
|
||||
tvPayload = await loadTVSourcePage({ selectedId: currentSourceId });
|
||||
sourceCache.clear();
|
||||
cacheTVSources(tvPayload);
|
||||
return tvPayload;
|
||||
}
|
||||
|
||||
@@ -1007,7 +1012,6 @@ export function initTVPanel() {
|
||||
initialized = true;
|
||||
|
||||
const {
|
||||
select,
|
||||
refreshBtn,
|
||||
iframe,
|
||||
video,
|
||||
@@ -1058,16 +1062,22 @@ export function initTVPanel() {
|
||||
showStatusMessage(earthMessage("status.newsPanel", { open: false }), "info");
|
||||
});
|
||||
|
||||
[select, document.getElementById("mobile-tv-source-select"), document.getElementById("tv-source-select")]
|
||||
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||
.forEach((selectEl) => {
|
||||
selectEl?.addEventListener("change", (event) => {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLSelectElement)) return;
|
||||
currentSourceId = target.value;
|
||||
renderSource(findSourceById(currentSourceId));
|
||||
});
|
||||
});
|
||||
sourceMenu = createTVSourceMenu({
|
||||
triggers: ["tv-source-select", "mobile-tv-source-select"]
|
||||
.map((id) => document.getElementById(id)).filter(Boolean),
|
||||
loadPage: loadTVSourcePage,
|
||||
formatSource: (source) => ({
|
||||
id: source.id,
|
||||
label: getTVSourceName(source),
|
||||
isDefault: source.id === tvPayload?.default_source_id,
|
||||
failed: failedSourceIds.has(source.id),
|
||||
}),
|
||||
onSelect: (sourceId) => {
|
||||
currentSourceId = sourceId;
|
||||
renderSourceOptions();
|
||||
renderSource(findSourceById(currentSourceId));
|
||||
},
|
||||
});
|
||||
|
||||
[metaToggle, document.getElementById("tv-meta-toggle")]
|
||||
.filter((element, index, array) => element && array.indexOf(element) === index)
|
||||
|
||||
@@ -8,6 +8,18 @@ let showVessels = false;
|
||||
let activeTrackLine = null;
|
||||
let vesselDataByKey = new Map();
|
||||
let vesselSnapshotGeneration = 0;
|
||||
let vesselRealtimeActive = false;
|
||||
let vesselTransportConnected = false;
|
||||
let vesselRealtimeEarth = null;
|
||||
let vesselRealtimeCallback = null;
|
||||
let vesselUpdateSequence = 0;
|
||||
let vesselUpdateTimer = null;
|
||||
const pendingVesselUpdates = new Map();
|
||||
const vesselUpdateVersions = new Map();
|
||||
const vesselUpdateTimes = new Map();
|
||||
const pendingVesselUpdateTimes = new Map();
|
||||
const VESSEL_UPDATE_INTERVAL_MS = 100;
|
||||
const VESSEL_SNAPSHOT_WINDOW_MS = 60 * 60 * 1000;
|
||||
let vesselRealtimeStats = {
|
||||
connected: false,
|
||||
updates: 0,
|
||||
@@ -107,9 +119,11 @@ function drawVesselShape(context, anchored, glow, color = "#ffffff") {
|
||||
function buildVesselMarkerData(feature) {
|
||||
const props = feature?.properties || {};
|
||||
const coordinates = feature?.geometry?.coordinates || [];
|
||||
if (coordinates[0] == null || coordinates[1] == null) return null;
|
||||
const longitude = Number(coordinates[0]);
|
||||
const latitude = Number(coordinates[1]);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)
|
||||
|| Math.abs(latitude) > 90 || Math.abs(longitude) > 180) return null;
|
||||
|
||||
const type = normalizeVesselType(props.vessel_type_name, props.vessel_type);
|
||||
const vesselTypeLabel = formatVesselTypeLabel(type, props.vessel_type_name);
|
||||
@@ -196,6 +210,7 @@ const vesselIconLayer = createInteractableLayer({
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
}),
|
||||
getItemId: markerDataToDedupeKey,
|
||||
getKind: (item) => item.type || "other",
|
||||
getRotationBin: getCourseBin,
|
||||
getBucketKey: (marker) => {
|
||||
@@ -263,6 +278,11 @@ export function getVesselPointerIntersections(options) {
|
||||
}
|
||||
|
||||
export function clearVesselData(earth) {
|
||||
vesselSnapshotGeneration += 1;
|
||||
pendingVesselUpdates.clear();
|
||||
vesselUpdateVersions.clear();
|
||||
vesselUpdateTimes.clear();
|
||||
pendingVesselUpdateTimes.clear();
|
||||
clearVesselSelection();
|
||||
vesselDataByKey.clear();
|
||||
vesselIconLayer.clearData(earth);
|
||||
@@ -270,12 +290,13 @@ export function clearVesselData(earth) {
|
||||
|
||||
export async function loadVessels(_scene, earth, options = {}) {
|
||||
const requestGeneration = ++vesselSnapshotGeneration;
|
||||
const initialSequence = vesselUpdateSequence;
|
||||
const requestedLimit = Number(options.limit ?? VESSEL_CONFIG.maxRenderedMarkers);
|
||||
const bbox = Array.isArray(options.bbox) && options.bbox.length === 4
|
||||
? options.bbox
|
||||
: GLOBAL_VESSEL_VIEWPORT.bbox;
|
||||
const zoom = Number.isFinite(Number(options.zoom))
|
||||
? Number(options.zoom)
|
||||
? Math.max(1, Math.min(20, Number(options.zoom)))
|
||||
: GLOBAL_VESSEL_VIEWPORT.zoom;
|
||||
const params = new URLSearchParams({ bbox: bbox.join(","), zoom: String(zoom) });
|
||||
if (Number.isFinite(requestedLimit) && requestedLimit > 0) {
|
||||
@@ -289,13 +310,32 @@ export async function loadVessels(_scene, earth, options = {}) {
|
||||
return { totalCount: getVesselCount(), stats: {} };
|
||||
}
|
||||
|
||||
clearVesselData(earth);
|
||||
let markerData = dedupeVesselFeatures(features);
|
||||
if (Number.isFinite(requestedLimit) && requestedLimit > 0) {
|
||||
markerData = markerData.slice(0, requestedLimit);
|
||||
}
|
||||
vesselDataByKey = new Map(markerData.map((item) => [markerDataToDedupeKey(item), item]));
|
||||
vesselIconLayer.setData(markerData);
|
||||
const nextData = new Map(markerData.map((item) => [markerDataToDedupeKey(item), item]));
|
||||
const snapshotTime = Date.parse(payload?.generated_at);
|
||||
const snapshotMayBeTruncated = requestedLimit > 0 && features.length >= requestedLimit;
|
||||
vesselDataByKey.forEach((item, key) => {
|
||||
const incoming = nextData.get(key);
|
||||
if ((!incoming && snapshotMayBeTruncated && Date.parse(item.received_at) >= Date.now() - VESSEL_SNAPSHOT_WINDOW_MS)
|
||||
|| (incoming && isOlderVesselUpdate(incoming, item))
|
||||
|| (Number.isFinite(snapshotTime) && (vesselUpdateTimes.get(key) || 0) >= snapshotTime)
|
||||
|| (vesselUpdateVersions.get(key) || 0) > initialSequence) nextData.set(key, item);
|
||||
});
|
||||
vesselUpdateVersions.forEach((version, key) => {
|
||||
if (!vesselDataByKey.has(key) && (version > initialSequence
|
||||
|| (Number.isFinite(snapshotTime) && (vesselUpdateTimes.get(key) || 0) >= snapshotTime))) nextData.delete(key);
|
||||
});
|
||||
vesselDataByKey = nextData;
|
||||
vesselIconLayer.updateItems(Array.from(nextData.values()), { replace: true });
|
||||
for (const key of vesselUpdateVersions.keys()) {
|
||||
if (!nextData.has(key) && (!Number.isFinite(snapshotTime) || (vesselUpdateTimes.get(key) || 0) < snapshotTime)) {
|
||||
vesselUpdateVersions.delete(key);
|
||||
vesselUpdateTimes.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
vesselIconLayer.attach(earth);
|
||||
vesselIconLayer.setVisible(showVessels);
|
||||
@@ -306,12 +346,93 @@ export async function loadVessels(_scene, earth, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function startVesselRealtime(_earth, { onUpdate } = {}) {
|
||||
export function startVesselRealtime(earth, { onUpdate } = {}) {
|
||||
vesselRealtimeActive = true;
|
||||
vesselRealtimeEarth = earth;
|
||||
vesselRealtimeCallback = onUpdate;
|
||||
vesselRealtimeStats.connected = vesselTransportConnected;
|
||||
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
|
||||
}
|
||||
|
||||
export function setVesselRealtimeConnected(connected) {
|
||||
vesselTransportConnected = Boolean(connected);
|
||||
vesselRealtimeStats.connected = vesselRealtimeActive && vesselTransportConnected;
|
||||
vesselRealtimeCallback?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
|
||||
}
|
||||
|
||||
function isOlderVesselUpdate(incoming, existing) {
|
||||
const incomingTime = Date.parse(incoming?.received_at);
|
||||
const existingTime = Date.parse(existing?.received_at);
|
||||
return Number.isFinite(incomingTime) && Number.isFinite(existingTime) && incomingTime < existingTime;
|
||||
}
|
||||
|
||||
function flushVesselRealtimeUpdates() {
|
||||
vesselUpdateTimer = null;
|
||||
if (!vesselRealtimeActive || !pendingVesselUpdates.size) return;
|
||||
const updates = [];
|
||||
let removed = false;
|
||||
pendingVesselUpdates.forEach((item, key) => {
|
||||
if (item.action === "remove") {
|
||||
removed = vesselDataByKey.delete(key) || removed;
|
||||
vesselUpdateVersions.set(key, ++vesselUpdateSequence);
|
||||
vesselUpdateTimes.set(key, pendingVesselUpdateTimes.get(key) || Date.now());
|
||||
return;
|
||||
}
|
||||
if (isOlderVesselUpdate(item, vesselDataByKey.get(key))) return;
|
||||
vesselDataByKey.set(key, item);
|
||||
vesselUpdateVersions.set(key, ++vesselUpdateSequence);
|
||||
vesselUpdateTimes.set(key, pendingVesselUpdateTimes.get(key) || Date.now());
|
||||
updates.push(item);
|
||||
});
|
||||
pendingVesselUpdates.clear();
|
||||
pendingVesselUpdateTimes.clear();
|
||||
if (!updates.length && !removed) return;
|
||||
vesselIconLayer.updateItems(removed ? Array.from(vesselDataByKey.values()) : updates, { replace: removed });
|
||||
vesselIconLayer.attach(vesselRealtimeEarth);
|
||||
vesselIconLayer.setVisible(showVessels);
|
||||
vesselRealtimeStats.updates += updates.length;
|
||||
vesselRealtimeStats.lastBatchSize = updates.length;
|
||||
vesselRealtimeStats.lastUpdateAt = new Date().toISOString();
|
||||
vesselRealtimeCallback?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
|
||||
}
|
||||
|
||||
export function applyVesselRealtimeFrame(payload = {}, timestamp = new Date().toISOString()) {
|
||||
if (!vesselRealtimeActive || !Array.isArray(payload.vessels)) return;
|
||||
const eventTime = Date.parse(timestamp) || Date.now();
|
||||
for (const vessel of payload.vessels) {
|
||||
if (vessel?.mmsi == null) continue;
|
||||
if (vessel.action === "remove") {
|
||||
const key = `mmsi:${String(vessel.mmsi).trim()}`;
|
||||
pendingVesselUpdates.set(key, vessel);
|
||||
pendingVesselUpdateTimes.set(key, eventTime);
|
||||
continue;
|
||||
}
|
||||
const item = buildVesselMarkerData({
|
||||
properties: { ...vessel, name: vessel.name || `MMSI ${vessel.mmsi}` },
|
||||
geometry: { coordinates: [vessel.lon, vessel.lat] },
|
||||
});
|
||||
if (!item) continue;
|
||||
const key = markerDataToDedupeKey(item);
|
||||
if (isOlderVesselUpdate(item, pendingVesselUpdates.get(key) || vesselDataByKey.get(key))) continue;
|
||||
pendingVesselUpdates.set(key, item);
|
||||
pendingVesselUpdateTimes.set(key, eventTime);
|
||||
}
|
||||
if (pendingVesselUpdates.size && !vesselUpdateTimer) {
|
||||
vesselUpdateTimer = setTimeout(flushVesselRealtimeUpdates, VESSEL_UPDATE_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export function stopVesselRealtime() {
|
||||
vesselSnapshotGeneration += 1;
|
||||
vesselRealtimeActive = false;
|
||||
vesselRealtimeEarth = null;
|
||||
vesselRealtimeCallback = null;
|
||||
clearTimeout(vesselUpdateTimer);
|
||||
vesselUpdateTimer = null;
|
||||
pendingVesselUpdates.clear();
|
||||
vesselUpdateVersions.clear();
|
||||
vesselUpdateTimes.clear();
|
||||
pendingVesselUpdateTimes.clear();
|
||||
vesselRealtimeStats = {
|
||||
connected: false,
|
||||
updates: 0,
|
||||
|
||||
@@ -3173,7 +3173,20 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
})()
|
||||
setStates((current) => {
|
||||
const next = current.filter((state) => state.section.key !== result.section.key)
|
||||
next.push(result)
|
||||
const previous = current.find((state) => state.section.key === result.section.key)
|
||||
const endpoints = arrayAt(result.raw, 'endpoints')
|
||||
const failedPreset = endpoints.find((endpoint) => endpoint.key === 'providerPresets' && endpoint.error)
|
||||
const previousPresets = endpointData(previous?.raw, 'providerPresets')
|
||||
if (failedPreset && previousPresets && isObjectRecord(result.raw)) {
|
||||
const previousRows = dataArray(previous?.raw).filter((row) => row.__endpointKey === 'providerPresets')
|
||||
next.push(normalizeSectionState(result.section, {
|
||||
...result.raw,
|
||||
endpoints: endpoints.map((endpoint) => endpoint === failedPreset ? { ...endpoint, data: previousPresets } : endpoint),
|
||||
data: [...dataArray(result.raw), ...previousRows],
|
||||
}))
|
||||
} else {
|
||||
next.push(result)
|
||||
}
|
||||
return next.sort((a, b) => config.sections.findIndex((item) => item.key === a.section.key) - config.sections.findIndex((item) => item.key === b.section.key))
|
||||
})
|
||||
if (section.key === activeSectionKey) {
|
||||
@@ -3643,15 +3656,15 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
try {
|
||||
await axios.request({ method, url: apiPath(path), data: body })
|
||||
toast({
|
||||
title: options.successTitle || `${label}已完成`,
|
||||
description: options.successDescription || '后端已接受本次变更。',
|
||||
title: localizeAdminText(options.successTitle || `${label}已完成`, locale),
|
||||
description: localizeAdminText(options.successDescription || '后端已接受本次变更。', locale),
|
||||
tone: 'success',
|
||||
})
|
||||
if (refresh) await load()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: `${label}失败`,
|
||||
description: actionErrorMessage(error),
|
||||
title: localizeAdminText(`${label}失败`, locale),
|
||||
description: localizeAdminText(actionErrorMessage(error), locale),
|
||||
tone: 'error',
|
||||
})
|
||||
} finally {
|
||||
@@ -3747,14 +3760,14 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const response = await axios.post(apiPath(path), body)
|
||||
const result = connectionResult(response.data)
|
||||
toast({
|
||||
title: `${label}${result.ok ? '连通性正常' : '连通性失败'}`,
|
||||
description: result.message,
|
||||
title: localizeAdminText(`${label}${result.ok ? '连通性正常' : '连通性失败'}`, locale),
|
||||
description: localizeAdminText(result.message, locale),
|
||||
tone: result.ok ? 'success' : 'error',
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: `${label}连通性失败`,
|
||||
description: actionErrorMessage(error),
|
||||
title: localizeAdminText(`${label}连通性失败`, locale),
|
||||
description: localizeAdminText(actionErrorMessage(error), locale),
|
||||
tone: 'error',
|
||||
})
|
||||
} finally {
|
||||
@@ -4811,6 +4824,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const webProviders = objectAt(webSearch, 'providers')
|
||||
const ocr = objectAt(integrations, 'ocr')
|
||||
const providerPresets = providerPresetRows(activeState)
|
||||
const providerCatalogError = arrayAt(activeState.raw, 'endpoints').some((endpoint) => endpoint.key === 'providerPresets' && endpoint.error)
|
||||
const webPresets = webSearchPresetRows(activeState)
|
||||
|
||||
let groups: HierarchyGroup[] = []
|
||||
@@ -4852,6 +4866,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
timeout_seconds: Number(providerConfig.timeout_seconds || (canUseRuntimeConfig ? aiProvider.timeout_seconds : 0) || 60),
|
||||
retry_attempts: Number(providerConfig.retry_attempts || (canUseRuntimeConfig ? aiProvider.retry_attempts : 0) || 2),
|
||||
models: Array.isArray(preset.models) ? preset.models : [],
|
||||
model_provider_apis: objectAt(preset, 'model_provider_apis'),
|
||||
__fallbackSecrets: providerSecretIsRuntime || (providerRuntimeSecretConfigured && !providerOwnSecretConfigured) ? ['api_key'] : [],
|
||||
__defaultDraft: {
|
||||
service_url: text(aiProvider.service_url, ''),
|
||||
@@ -5028,6 +5043,9 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
window.queueMicrotask(() => selectHierarchyGroup(activeGroup, false))
|
||||
}
|
||||
const record = activeGroup ? draftRecord(hierarchyDraft, activeGroup.record) : {}
|
||||
const availableModels = Array.isArray(activeGroup?.record.models)
|
||||
? activeGroup.record.models.filter((model): model is string => typeof model === 'string')
|
||||
: []
|
||||
const activeProviderId = text(record.provider || record.default_provider, '')
|
||||
const isActiveDefault = activeSection.key === 'integrations'
|
||||
? Boolean(activeProviderId && activeProviderId === text(aiProvider.default_provider || selectedRuntimeProvider(aiProvider), ''))
|
||||
@@ -5050,6 +5068,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
{ key: 'provider', label: '供应商' },
|
||||
{ key: 'provider_api', label: '协议适配', type: 'select', options: [
|
||||
{ value: 'openai-completions', label: 'OpenAI Chat Completions' },
|
||||
{ value: 'openai-responses', label: 'OpenAI Responses' },
|
||||
{ value: 'anthropic-messages', label: 'Anthropic Messages' },
|
||||
{ value: 'ollama-generate', label: 'Ollama Generate' },
|
||||
] },
|
||||
@@ -5278,7 +5297,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
</div>
|
||||
<div className="an-toolbar">
|
||||
{activeSection.key === 'integrations' && activeGroup ? (
|
||||
<Button size="icon" variant="subtle" title="刷新当前 Provider 的模型配置" aria-label="刷新当前 Provider 的模型配置" onClick={() => void requestAction('刷新模型列表', 'post', `/settings/integrations/ai-provider/presets/${encodeURIComponent(text(record.provider, ''))}/refresh`, undefined, { refresh: false, successDescription: '模型预设已刷新,当前表单未保存。' })} loading={actionLoading}><RefreshCw size={15} /></Button>
|
||||
<Button size="icon" variant="subtle" title="刷新当前 Provider 的模型配置" aria-label="刷新当前 Provider 的模型配置" onClick={() => void requestAction('刷新模型列表', 'post', `/settings/integrations/ai-provider/presets/${encodeURIComponent(text(record.provider, ''))}/refresh`, sanitizeSecretDrafts(stripInternalFields(record), activeGroup.record, ['api_key', 'service_token'], revealedFor('ai_provider')), { successDescription: '模型列表已更新,当前模型与未保存配置保持不变。' })} loading={actionLoading}><RefreshCw size={15} /></Button>
|
||||
) : null}
|
||||
{activeGroup?.key.startsWith('web_search:') ? (
|
||||
<>
|
||||
@@ -5314,12 +5333,23 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
{activeGroup ? (
|
||||
<div className="an-hierarchy-form">
|
||||
<FieldGrid record={activeGroup.record} draft={hierarchyDraft} onDraftChange={setHierarchyDraft} fields={fields} searchGroupKey={activeGroup.key} />
|
||||
{activeSection.key === 'integrations' && Array.isArray(record.models) && record.models.length ? (
|
||||
{activeSection.key === 'integrations' && providerCatalogError ? (
|
||||
<p role="alert">{localizeAdminText('模型列表加载失败,请重试;已加载的模型列表会保留。', locale)}</p>
|
||||
) : null}
|
||||
{activeSection.key === 'integrations' && availableModels.length > 0 ? (
|
||||
<div className="an-sub-list">
|
||||
<strong>{localizeAdminText('可选模型', locale)}</strong>
|
||||
<div>{(record.models as unknown[]).map((model) => <button key={String(model)} type="button" data-admin-search-target={`${activeGroup.key}:model:${String(model)}`} data-admin-search-text={String(model)} onClick={() => setHierarchyDraft(setNestedDraftField(hierarchyDraft, activeGroup.record, 'model', String(model)))}>{String(model)}</button>)}</div>
|
||||
<div>{availableModels.map((model) => <button key={model} type="button" data-admin-search-target={`${activeGroup.key}:model:${model}`} data-admin-search-text={model} onClick={() => {
|
||||
const customOpenAI = record.provider === 'openai' && !/^https:\/\/api\.openai\.com(?:\/|$)/i.test(text(record.base_url, ''))
|
||||
const modelApi = customOpenAI ? '' : text(objectAt(activeGroup.record, 'model_provider_apis')[model], '')
|
||||
const nextDraft = setNestedDraftField(hierarchyDraft, activeGroup.record, 'model', model)
|
||||
setHierarchyDraft(modelApi ? setNestedDraftField(nextDraft, activeGroup.record, 'provider_api', modelApi) : nextDraft)
|
||||
}}>{model}</button>)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{activeSection.key === 'integrations' && !providerCatalogError && availableModels.length === 0 ? (
|
||||
<p>{localizeAdminText('当前没有可选模型,请配置供应商并刷新模型列表。', locale)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : <EmptyState title={localizeAdminText('暂无分组', locale)} description={localizeAdminText('当前分区没有可配置项。', locale)} />}
|
||||
</Scrollbar>
|
||||
@@ -6608,7 +6638,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
}
|
||||
|
||||
if (config === configs.ai && endpointKey === 'providerPresets' && id) {
|
||||
actions.push(<Button key="refresh-preset" variant="primary" onClick={() => void requestAction('刷新模型列表', 'post', `/settings/integrations/ai-provider/presets/${encodeURIComponent(id)}/refresh`, undefined, { refresh: false, successDescription: '模型预设已刷新,未保存 provider 表单。' })} loading={actionLoading}><RefreshCw size={15} />刷新模型</Button>)
|
||||
actions.push(<Button key="refresh-preset" variant="primary" onClick={() => void requestAction('刷新模型列表', 'post', `/settings/integrations/ai-provider/presets/${encodeURIComponent(id)}/refresh`, undefined, { successDescription: '模型列表已更新,当前模型与未保存配置保持不变。' })} loading={actionLoading}><RefreshCw size={15} />刷新模型</Button>)
|
||||
}
|
||||
|
||||
if (config === configs.ai && endpointKey === 'prompts' && id) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'OpenAI Chat Completions': 'OpenAI Chat Completions',
|
||||
'OpenAI Responses': 'OpenAI Responses',
|
||||
'AI Provider连通性正常': 'AI Provider connectivity OK',
|
||||
'AI Provider连通性失败': 'AI Provider connectivity failed',
|
||||
'3D 模型': '3D models',
|
||||
'AI 生成教程': 'AI-generated guide',
|
||||
'AI 分区': 'AI sections',
|
||||
@@ -74,6 +78,23 @@ export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'其他': 'Other',
|
||||
'刷新': 'Refresh',
|
||||
'刷新当前 Provider 的模型配置': 'Refresh current provider model configuration',
|
||||
'刷新模型列表已完成': 'Model list refreshed',
|
||||
'刷新模型列表失败': 'Model list refresh failed',
|
||||
'模型列表已更新,当前模型与未保存配置保持不变。': 'Model list updated. Your current model and unsaved settings are unchanged.',
|
||||
'模型列表刷新失败,已保留上次模型列表。': 'Model catalog refresh failed. The previous model list has been kept.',
|
||||
'模型列表加载失败,请重试;已加载的模型列表会保留。': 'Model list failed to load. Retry; the previously loaded list is retained.',
|
||||
'当前没有可选模型,请配置供应商并刷新模型列表。': 'No models are available. Configure the provider and refresh the model list.',
|
||||
'请先配置当前供应商的 API Key,再刷新模型列表。': 'Configure this provider’s API key before refreshing models.',
|
||||
'模型目录查询成功,当前模型已找到;尚未执行生成调用。': 'Model catalog is reachable and contains the selected model. Generation has not been tested.',
|
||||
'模型目录查询成功,但当前模型不在目录中:': 'Model catalog is reachable, but does not contain the selected model: ',
|
||||
'模型目录请求超时,请检查网络后重试。': 'Model catalog request timed out. Check the network and retry.',
|
||||
'API Key 验证失败,请检查当前供应商的凭证。': 'API key authentication failed. Check this provider’s credentials.',
|
||||
'当前 API Key 无权访问该模型目录,请检查账号权限和服务地域。': 'This API key cannot access the model catalog. Check account permissions and service region.',
|
||||
'模型目录接口不存在,请检查基础地址、地域和接口协议。': 'Model catalog endpoint was not found. Check the base URL, region, and protocol.',
|
||||
'供应商请求限流,请稍后重试。': 'The provider rate limit was reached. Retry later.',
|
||||
'无法连接模型目录,请检查基础地址和网络。': 'Cannot reach the model catalog. Check the base URL and network.',
|
||||
'模型目录响应无效,请稍后重试。': 'Invalid model catalog response. Retry later.',
|
||||
'已保留上次模型列表。': 'The previous model list has been kept.',
|
||||
'刷新间隔(秒)': 'Refresh interval (seconds)',
|
||||
'刷新模型': 'Refresh models',
|
||||
'刷新模型列表': 'Refresh model list',
|
||||
|
||||
60
planet.sh
60
planet.sh
@@ -2122,6 +2122,7 @@ wait_for_http() {
|
||||
local attempts="$2"
|
||||
local interval="$3"
|
||||
local service_name="$4"
|
||||
local failure_probe="${5:-}"
|
||||
local attempt=1
|
||||
local wait_message="检查${service_name}健康检查"
|
||||
local wait_detail_prefix="请求"
|
||||
@@ -2130,6 +2131,10 @@ wait_for_http() {
|
||||
set_wait_detail "${wait_detail_prefix} ${url},已等待 0 秒"
|
||||
|
||||
while [ "$attempt" -le "$attempts" ]; do
|
||||
if [ -n "$failure_probe" ] && "$failure_probe"; then
|
||||
clear_wait_spinner
|
||||
return 2
|
||||
fi
|
||||
if http_ok "$url"; then
|
||||
finish_wait_spinner "${service_name}已就绪"
|
||||
return 0
|
||||
@@ -2457,6 +2462,18 @@ start_postgres_service() {
|
||||
}
|
||||
|
||||
# Backend lifecycle helpers
|
||||
backend_startup_failed() {
|
||||
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
# Uvicorn's reloader can stay alive after the application worker has failed.
|
||||
if log_matches "$BACKEND_LOG_FILE" 'Application startup failed[.]|Error loading ASGI app'; then
|
||||
return 0
|
||||
fi
|
||||
log_matches "$BACKEND_LOG_FILE" '^Process (SpawnProcess|ForkProcess).*:' &&
|
||||
log_matches "$BACKEND_LOG_FILE" '^(ModuleNotFoundError|ImportError|SyntaxError):'
|
||||
}
|
||||
|
||||
cleanup_backend_processes() {
|
||||
local backend_port="${1:-$DEFAULT_BACKEND_PORT}"
|
||||
local tracked_pid=""
|
||||
@@ -2490,6 +2507,7 @@ start_backend_with_retry() {
|
||||
local backend_port="$1"
|
||||
local retry=1
|
||||
local backend_bind_host="0.0.0.0"
|
||||
local readiness_status=0
|
||||
|
||||
while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do
|
||||
if ! cleanup_backend_processes "$backend_port"; then
|
||||
@@ -2508,7 +2526,9 @@ start_backend_with_retry() {
|
||||
WAIT_VERBOSE_LOG_FILE="$BACKEND_LOG_FILE"
|
||||
fi
|
||||
|
||||
if wait_for_http "http://localhost:${backend_port}/health" "$BACKEND_HEALTH_CHECK_ATTEMPTS" "$BACKEND_HEALTH_CHECK_INTERVAL" "后端"; then
|
||||
readiness_status=0
|
||||
wait_for_http "http://localhost:${backend_port}/health" "$BACKEND_HEALTH_CHECK_ATTEMPTS" "$BACKEND_HEALTH_CHECK_INTERVAL" "后端" backend_startup_failed || readiness_status=$?
|
||||
if [ "$readiness_status" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -2523,6 +2543,10 @@ start_backend_with_retry() {
|
||||
report_backend_port_conflict_if_needed "$backend_port" "$BACKEND_LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
if [ "$readiness_status" -eq 2 ]; then
|
||||
log_error "后端进程已退出或应用初始化失败,停止等待与重复启动"
|
||||
return 2
|
||||
fi
|
||||
animate_wait_spinner "后端启动未就绪,准备再次尝试" "$BACKEND_HEALTH_CHECK_INTERVAL"
|
||||
retry=$((retry + 1))
|
||||
done
|
||||
@@ -2544,14 +2568,12 @@ start_ai_provider_service() {
|
||||
if [ "$AI_PROVIDER_RECREATE_REQUIRED" -eq 1 ]; then
|
||||
if recreate_ai_provider_container "$ai_provider_port"; then
|
||||
AI_PROVIDER_RECREATE_REQUIRED=0
|
||||
if wait_for_container_health "$AI_PROVIDER_CONTAINER_NAME" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" &&
|
||||
wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
|
||||
if wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
elif docker start "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || compose_up up -d aiprovider >/dev/null 2>&1; then
|
||||
if wait_for_container_health "$AI_PROVIDER_CONTAINER_NAME" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" &&
|
||||
wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
|
||||
if wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
@@ -2649,9 +2671,15 @@ start_backend_service() {
|
||||
local backend_port="$1"
|
||||
local backend_port_requested="$2"
|
||||
local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}"
|
||||
local backend_start_status=0
|
||||
|
||||
ensure_uv_backend_deps
|
||||
set_wait_detail "启动数据库"
|
||||
ensure_database_services_healthy
|
||||
if ! verify_backend_database_connection; then
|
||||
log_error "后端数据库连接检查失败,停止启动"
|
||||
exit 1
|
||||
fi
|
||||
log_success "启动数据库已就绪"
|
||||
|
||||
# Backend depends on AI Provider reachability, but a backend-only restart
|
||||
@@ -2669,9 +2697,13 @@ start_backend_service() {
|
||||
fi
|
||||
|
||||
set_wait_detail "启动后端"
|
||||
ensure_uv_backend_deps
|
||||
if ! start_backend_with_retry "$backend_port"; then
|
||||
log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次"
|
||||
start_backend_with_retry "$backend_port" || backend_start_status=$?
|
||||
if [ "$backend_start_status" -ne 0 ]; then
|
||||
if [ "$backend_start_status" -eq 2 ]; then
|
||||
log_error "后端启动失败,请检查启动日志"
|
||||
else
|
||||
log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次"
|
||||
fi
|
||||
tail -10 "$BACKEND_LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
@@ -3523,16 +3555,6 @@ parse_service_args() {
|
||||
validate_port "$MOTION_AGENT_PORT"
|
||||
}
|
||||
|
||||
cleanup_exit_containers() {
|
||||
local exit_containers
|
||||
exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)"
|
||||
if [ -n "$exit_containers" ]; then
|
||||
log_step "清理残留 Exit 容器"
|
||||
printf "%s\n" "$exit_containers" | awk '/^[0-9a-f]{12,64}$/' | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||
log_success "残留容器已清理"
|
||||
fi
|
||||
}
|
||||
|
||||
stop_container_if_running() {
|
||||
local container_name="$1"
|
||||
local service_name="$2"
|
||||
@@ -4293,7 +4315,6 @@ verify_allow_lan_access() {
|
||||
# Top-level commands
|
||||
start() {
|
||||
parse_service_args "$@"
|
||||
cleanup_exit_containers
|
||||
prepare_allow_lan_public_ports
|
||||
|
||||
typeset -g START_RUN_ACTIVE=1
|
||||
@@ -4479,7 +4500,6 @@ stop_local_services_for_restart() {
|
||||
|
||||
restart() {
|
||||
parse_service_args "$@"
|
||||
cleanup_exit_containers
|
||||
local state_backend_port=""
|
||||
local state_frontend_port=""
|
||||
local state_frontend_scheme=""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.74.3"
|
||||
version = "0.74.5"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
@@ -32,6 +32,14 @@ dependencies = [
|
||||
package = false
|
||||
|
||||
[dependency-groups]
|
||||
aiprovider = [
|
||||
"fastapi==0.128.0",
|
||||
"httpx==0.28.1",
|
||||
"pydantic==2.12.5",
|
||||
"pydantic-settings==2.12.0",
|
||||
"python-dotenv==1.2.1",
|
||||
"uvicorn[standard]==0.40.0",
|
||||
]
|
||||
dev = [
|
||||
"black>=24.0.0",
|
||||
"pytest>=7.4.0",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const baseUrl = process.env.PLANET_FRONTEND_SMOKE_URL || 'http://127.0.0.1:4173'
|
||||
const smokeProgressMode = process.env.PLANET_FRONTEND_SMOKE_PROGRESS || 'phase'
|
||||
const SMOKE_RENDER_TIMEOUT_MS = 30000
|
||||
|
||||
function smokeProgress(label, mode = 'phase') {
|
||||
if (smokeProgressMode === '0' || smokeProgressMode === 'off') return
|
||||
@@ -35,6 +37,16 @@ const publicEnglishI18nRoutes = [
|
||||
const desktopViewport = { width: 1440, height: 900 }
|
||||
const mobileViewport = { width: 390, height: 844 }
|
||||
const zoomLevels = [1.25, 1.5]
|
||||
const earthGeoJsonPaths = new Set([
|
||||
'/visualization/geo/cables',
|
||||
'/visualization/geo/landing-points',
|
||||
'/visualization/geo/compute-centers',
|
||||
'/visualization/geo/satellites',
|
||||
'/visualization/geo/bgp-anomalies',
|
||||
'/visualization/geo/bgp-incidents',
|
||||
'/visualization/geo/bgp-collectors',
|
||||
'/vessels/snapshot',
|
||||
])
|
||||
const interactionChecks = [
|
||||
'root redirects earth',
|
||||
'unknown route login fallback',
|
||||
@@ -44,6 +56,8 @@ const interactionChecks = [
|
||||
'section tab collection credentials',
|
||||
'section tab settings smtp',
|
||||
'connection test input ai provider',
|
||||
'ai model catalog refresh preserves draft and survives reload',
|
||||
'ai model catalog refresh failure preserves last list',
|
||||
'connection test input websearch disabled',
|
||||
'logs raw tab',
|
||||
'users add dialog',
|
||||
@@ -388,21 +402,16 @@ function sleep(ms) {
|
||||
}
|
||||
|
||||
async function expectVisibleText(page, text, route) {
|
||||
const locator = page.getByText(text, { exact: false }).first()
|
||||
const deadline = Date.now() + 10000
|
||||
while (Date.now() < deadline) {
|
||||
const matches = page.getByText(text, { exact: false })
|
||||
const count = await matches.count().catch(() => 0)
|
||||
for (let index = 0; index < Math.min(count, 25); index += 1) {
|
||||
if (await matches.nth(index).isVisible().catch(() => false)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
await sleep(100)
|
||||
const matches = page.getByText(text, { exact: false })
|
||||
try {
|
||||
await matches.filter({ visible: true }).first().waitFor({
|
||||
state: 'visible',
|
||||
timeout: SMOKE_RENDER_TIMEOUT_MS,
|
||||
})
|
||||
} catch (error) {
|
||||
const bodyText = await page.locator('body').innerText({ timeout: 2000 }).catch(() => '')
|
||||
throw new Error(`${route}: expected visible text ${JSON.stringify(text)}; body was ${JSON.stringify(bodyText.slice(0, 500))}; ${await matches.count().catch(() => 0)} match(es) were found but none were visible`, { cause: error })
|
||||
}
|
||||
|
||||
const bodyText = await page.locator('body').innerText({ timeout: 2000 }).catch(() => '')
|
||||
throw new Error(`${route}: expected visible text ${JSON.stringify(text)}; body was ${JSON.stringify(bodyText.slice(0, 500))}; ${await locator.count().catch(() => 0)} match(es) were found but none were visible`)
|
||||
}
|
||||
|
||||
async function clickFirstVisible(locator, label) {
|
||||
@@ -795,6 +804,10 @@ function apiPayloadFor(requestUrl, method) {
|
||||
const url = new URL(requestUrl)
|
||||
const path = url.pathname.replace(/^\/api\/v1/, '') || '/'
|
||||
|
||||
if (method === 'GET' && earthGeoJsonPaths.has(path)) {
|
||||
return jsonResponse({ type: 'FeatureCollection', features: [], count: 0, generated_at: now })
|
||||
}
|
||||
|
||||
if (method === 'POST' && path === '/auth/login') {
|
||||
return jsonResponse({ detail: '登录失败,请检查账号或密码。' }, 401)
|
||||
}
|
||||
@@ -904,13 +917,8 @@ function apiPayloadFor(requestUrl, method) {
|
||||
if (path === '/earth/boundaries/status') return jsonResponse({ status: 'ready', provider: 'low', updated_at: now })
|
||||
if (path === '/earth/boundaries/build/status') return jsonResponse({ status: 'idle', progress: 0 })
|
||||
if (path === '/settings/tv') return jsonResponse({ tv: { default_source_id: 'smoke-tv', sources: [{ id: 'smoke-tv', name: 'Smoke TV', source_type: 'hls', stream_url: 'https://example.invalid/live.m3u8', is_enabled: true, sort_order: 10 }] } })
|
||||
if (path === '/tv/streams') return jsonResponse({
|
||||
generated_at: now,
|
||||
latest_updated_at: now,
|
||||
source_count: 1,
|
||||
default_source_id: 'cctv-news',
|
||||
selected_source: { id: 'cctv-news' },
|
||||
sources: [{
|
||||
if (path === '/tv/streams') {
|
||||
const source = {
|
||||
id: 'cctv-news',
|
||||
name: '央视新闻',
|
||||
provider: '中国媒体',
|
||||
@@ -921,8 +929,21 @@ function apiPayloadFor(requestUrl, method) {
|
||||
notes: '默认直播源',
|
||||
is_enabled: true,
|
||||
sort_order: 10,
|
||||
}],
|
||||
})
|
||||
}
|
||||
return jsonResponse({
|
||||
generated_at: now,
|
||||
latest_updated_at: now,
|
||||
source_count: 1,
|
||||
default_source_id: source.id,
|
||||
selected_source: source,
|
||||
sources: [source],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
has_more: false,
|
||||
next_offset: null,
|
||||
})
|
||||
}
|
||||
if (path === '/news/earth-feed') return jsonResponse({
|
||||
generated_at: now,
|
||||
focus: { lat: 0, lon: 0, region: 'global', label: 'Global Focus', display_region: 'Global' },
|
||||
@@ -1211,7 +1232,7 @@ async function runAdminInteractionChecks(browser, failures, consoleErrors) {
|
||||
await page.goto(urlFor('/ai?section=integrations'), { waitUntil: 'domcontentloaded' })
|
||||
await expectVisibleText(page, 'LLM 基础地址', 'connection test input ai provider')
|
||||
await clickFirstVisible(page.locator('.an-connection-test-input button'), 'connection test input ai provider')
|
||||
await expectVisibleText(page, 'AI Provider连通性正常', 'connection test input ai provider')
|
||||
await expectVisibleText(page, 'AI 提供方连通性正常', 'connection test input ai provider')
|
||||
|
||||
await page.goto(urlFor('/ai?section=tools'), { waitUntil: 'domcontentloaded' })
|
||||
await clickFirstVisible(page.getByRole('button', { name: /tavily/i }), 'connection test input websearch provider')
|
||||
@@ -1309,6 +1330,79 @@ async function runAdminInteractionChecks(browser, failures, consoleErrors) {
|
||||
}
|
||||
}
|
||||
|
||||
async function runAIModelCatalogChecks(browser, failures, consoleErrors) {
|
||||
for (const viewport of [desktopViewport, mobileViewport]) {
|
||||
const context = await browser.newContext({ viewport })
|
||||
await installApiMocks(context)
|
||||
bindConsoleCollection(context, consoleErrors)
|
||||
const page = await context.newPage()
|
||||
await seedAuthenticatedState(page)
|
||||
let models = ['MiniMax-M2.7']
|
||||
let failRefresh = false
|
||||
let failListing = false
|
||||
let refreshBody = null
|
||||
let integrationWrites = 0
|
||||
page.on('request', (request) => {
|
||||
if (request.method() === 'PUT' && new URL(request.url()).pathname.endsWith('/settings/integrations')) integrationWrites += 1
|
||||
})
|
||||
await page.route('**/settings/integrations/ai-provider/presets**', async (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
refreshBody = route.request().postDataJSON()
|
||||
if (failRefresh) return route.fulfill(jsonResponse({ detail: '模型列表刷新失败,已保留上次模型列表。' }, 502))
|
||||
models = ['MiniMax-M3', 'MiniMax-M2.7']
|
||||
} else if (failListing) {
|
||||
return route.fulfill(jsonResponse({ detail: 'Catalog unavailable' }, 500))
|
||||
}
|
||||
const preset = { provider: 'minimax', label: 'MiniMax', model: models[0], models, provider_api: 'anthropic-messages' }
|
||||
await route.fulfill(jsonResponse({ data: route.request().method() === 'POST' ? preset : [preset] }))
|
||||
})
|
||||
const openProvider = async () => {
|
||||
await expectVisibleText(page, 'minimax', 'AI provider group')
|
||||
if (viewport.width < 768) await clickFirstVisible(page.getByRole('button', { name: /minimax/i }), 'mobile AI provider')
|
||||
await page.getByLabel('默认模型', { exact: true }).waitFor({ state: 'visible' })
|
||||
}
|
||||
try {
|
||||
smokeProgress(`AI model catalog refresh ${viewport.width}px`)
|
||||
await page.goto(urlFor('/ai?section=integrations'), { waitUntil: 'domcontentloaded' })
|
||||
await openProvider()
|
||||
const drafts = { '默认模型': 'custom-model', 'LLM 基础地址': 'https://custom.example.invalid/v1', 'LLM API Key': 'draft-api-key', '代理 Token': 'draft-service-token' }
|
||||
for (const [label, value] of Object.entries(drafts)) await page.getByLabel(label, { exact: true }).fill(value)
|
||||
await page.getByRole('button', { name: /刷新当前.*模型配置/ }).click()
|
||||
await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).waitFor({ state: 'visible' })
|
||||
for (const [label, value] of Object.entries(drafts)) assert.equal(await page.getByLabel(label, { exact: true }).inputValue(), value, `${label} draft was overwritten`)
|
||||
assert.equal(refreshBody.base_url, drafts['LLM 基础地址'], 'refresh must use the draft endpoint')
|
||||
assert.equal(refreshBody.api_key, drafts['LLM API Key'], 'refresh must use the draft credential')
|
||||
assert.equal(integrationWrites, 0, 'catalog refresh must not save runtime settings')
|
||||
await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).click()
|
||||
assert.equal(await page.getByLabel('默认模型', { exact: true }).inputValue(), 'MiniMax-M3')
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await openProvider()
|
||||
await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).waitFor({ state: 'visible' })
|
||||
assert.equal(await page.getByLabel('默认模型', { exact: true }).inputValue(), 'MiniMax-M2.7')
|
||||
if (process.env.PLANET_CATALOG_SCREENSHOT_DIR) {
|
||||
await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).scrollIntoViewIfNeeded()
|
||||
await page.screenshot({ path: `${process.env.PLANET_CATALOG_SCREENSHOT_DIR}/catalog-${viewport.width}.png`, fullPage: false })
|
||||
}
|
||||
failRefresh = true
|
||||
await page.getByRole('button', { name: /刷新当前.*模型配置/ }).click()
|
||||
await expectVisibleText(page, '模型列表刷新失败,已保留上次模型列表。', 'catalog failure')
|
||||
assert.equal(await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).count(), 1)
|
||||
assert.equal(await page.locator('.an-toast--success').count(), 0, 'failed refresh must not claim success')
|
||||
failRefresh = false
|
||||
failListing = true
|
||||
await page.getByRole('button', { name: /刷新当前.*模型配置/ }).click()
|
||||
await expectVisibleText(page, '模型列表加载失败,请重试;已加载的模型列表会保留。', 'catalog listing failure')
|
||||
assert.equal(await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).count(), 1, 'listing failure must preserve models')
|
||||
await checkNoFrameworkOverlay(page, 'AI catalog refresh')
|
||||
await checkNoGlobalHorizontalOverflow(page, 'AI catalog refresh')
|
||||
} catch (error) {
|
||||
failures.push(`AI catalog ${viewport.width}px: ${error instanceof Error ? error.message : String(error)}`)
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runDocsInteractionChecks(browser, failures, consoleErrors) {
|
||||
const context = await browser.newContext({ viewport: desktopViewport })
|
||||
await installApiMocks(context)
|
||||
@@ -1559,6 +1653,7 @@ async function main() {
|
||||
await earthFrame.waitForFunction(() => (
|
||||
!document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"]')?.hidden
|
||||
), null, { timeout: 5000 })
|
||||
await expectVisibleText(earthFrame, 'Earth Language', '/earth i18n settings')
|
||||
const systemText = await earthFrame.evaluate(() => (
|
||||
document.querySelector('.earth-settings-sheet [data-settings-tab-panel="system"]')?.innerText || ''
|
||||
))
|
||||
@@ -1927,7 +2022,7 @@ async function main() {
|
||||
document.getElementById('tv-source-status')?.innerText || '',
|
||||
document.getElementById('tv-source-catalog')?.innerText || '',
|
||||
document.getElementById('tv-source-notes')?.innerText || '',
|
||||
Array.from(document.querySelectorAll('#tv-source-select option')).map((option) => option.textContent || '').join(' '),
|
||||
document.getElementById('tv-source-select')?.innerText || '',
|
||||
].join('\n'))
|
||||
if (!tvText.includes('Default') || !tvText.includes('Built-in')) {
|
||||
throw new Error('/earth i18n settings: English TV panel is missing localized default/built-in labels')
|
||||
@@ -2015,6 +2110,7 @@ async function main() {
|
||||
})
|
||||
}
|
||||
await runAdminInteractionChecks(browser, failures, consoleErrors)
|
||||
await runAIModelCatalogChecks(browser, failures, consoleErrors)
|
||||
await runDocsInteractionChecks(browser, failures, consoleErrors)
|
||||
await runAuthInteractionChecks(browser, failures, consoleErrors)
|
||||
await browser.close()
|
||||
|
||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -43,6 +44,163 @@ def run_shell(functions: list[str], setup: str, action: str) -> subprocess.Compl
|
||||
|
||||
|
||||
class DatabaseLifecycleTests(unittest.TestCase):
|
||||
def test_ai_start_uses_host_readiness_without_waiting_for_docker_probe_schedule(self) -> None:
|
||||
for recreate in (0, 1):
|
||||
with self.subTest(recreate=recreate):
|
||||
result = run_shell(
|
||||
["start_ai_provider_service"],
|
||||
f"""
|
||||
AI_PROVIDER_RECREATE_REQUIRED={recreate}
|
||||
AI_PROVIDER_START_MAX_RETRIES=1
|
||||
AI_PROVIDER_HEALTH_CHECK_ATTEMPTS=10
|
||||
AI_PROVIDER_HEALTH_CHECK_INTERVAL=2
|
||||
AI_PROVIDER_CONTAINER_NAME=planet_aiprovider
|
||||
for fn in set_wait_detail write_ai_provider_runtime_env_file \
|
||||
ensure_ai_provider_image_current recreate_ai_provider_container docker; do
|
||||
functions[$fn]='return 0'
|
||||
done
|
||||
wait_for_container_health() {{ echo WAIT_FOR_DOCKER_SCHEDULE; return 1; }}
|
||||
wait_for_http() {{ echo "HOST_CHECK $1"; return 0; }}
|
||||
log_error() {{ :; }}
|
||||
""",
|
||||
"start_ai_provider_service 18010",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("HOST_CHECK http://localhost:18010/health", result.stdout)
|
||||
self.assertNotIn("WAIT_FOR_DOCKER_SCHEDULE", result.stdout)
|
||||
|
||||
def test_fatal_backend_startup_does_not_launch_three_identical_attempts(self) -> None:
|
||||
result = run_shell(
|
||||
["start_backend_with_retry"],
|
||||
f"""
|
||||
SCRIPT_DIR={ROOT}
|
||||
BACKEND_LOG_FILE=/dev/null
|
||||
BACKEND_PID_FILE=/dev/null
|
||||
BACKEND_MAX_RETRIES=3
|
||||
BACKEND_HEALTH_CHECK_ATTEMPTS=60
|
||||
BACKEND_HEALTH_CHECK_INTERVAL=2
|
||||
VERBOSE=0
|
||||
cleanup_backend_processes() {{ :; }}
|
||||
start_detached_command() {{ echo STARTED >&2; echo 42; }}
|
||||
write_pid_file() {{ :; }}
|
||||
wait_for_http() {{ return 2; }}
|
||||
kill() {{ :; }}
|
||||
backend_log_indicates_port_conflict() {{ return 1; }}
|
||||
animate_wait_spinner() {{ echo WAITED; }}
|
||||
log_error() {{ :; }}
|
||||
""",
|
||||
"start_backend_with_retry 8000",
|
||||
)
|
||||
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
|
||||
self.assertEqual(result.stderr.count("STARTED"), 1)
|
||||
self.assertNotIn("WAITED", result.stdout)
|
||||
|
||||
def test_start_keeps_stopped_containers_available_for_reuse(self) -> None:
|
||||
result = run_shell(
|
||||
["start"],
|
||||
"""
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=3000
|
||||
AI_PROVIDER_PORT=8010
|
||||
BACKEND_PORT_REQUESTED=0
|
||||
FRONTEND_PORT_REQUESTED=0
|
||||
FRONTEND_LAN_ENABLED=0
|
||||
MOTION_AGENT_REQUESTED=0
|
||||
for fn in parse_service_args prepare_allow_lan_public_ports print_splash \
|
||||
start_backend_service start_frontend_service write_port_state \
|
||||
verify_allow_lan_access log_success log_note; do
|
||||
functions[$fn]='return 0'
|
||||
done
|
||||
current_frontend_scheme() { echo http; }
|
||||
cleanup_exit_containers() { echo DELETED_STOPPED_CONTAINERS; }
|
||||
""",
|
||||
"start --non-motion-agent",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertNotIn("DELETED_STOPPED_CONTAINERS", result.stdout)
|
||||
|
||||
def test_start_checks_database_before_preparing_ai_and_starting_backend(self) -> None:
|
||||
for connection_status in (0, 1):
|
||||
with self.subTest(connection_status=connection_status):
|
||||
result = run_shell(
|
||||
["start_backend_service"],
|
||||
f"""
|
||||
BACKEND_MAX_RETRIES=3
|
||||
BACKEND_LOG_FILE=/dev/null
|
||||
for fn in set_wait_detail log_success log_note log_error; do
|
||||
functions[$fn]='return 0'
|
||||
done
|
||||
ensure_uv_backend_deps() {{ echo DEPS; }}
|
||||
ensure_database_services_healthy() {{ echo CONTAINERS; }}
|
||||
verify_backend_database_connection() {{ echo DB_CHECK; return {connection_status}; }}
|
||||
ai_provider_service_healthy() {{ return 1; }}
|
||||
start_ai_provider_service() {{ echo AI_STARTED; }}
|
||||
start_backend_with_retry() {{ echo BACKEND_STARTED; }}
|
||||
""",
|
||||
"start_backend_service 8000 0 8010",
|
||||
)
|
||||
self.assertIn("DB_CHECK", result.stdout)
|
||||
if connection_status:
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertNotIn("AI_STARTED", result.stdout)
|
||||
self.assertNotIn("BACKEND_STARTED", result.stdout)
|
||||
else:
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertLess(result.stdout.index("DB_CHECK"), result.stdout.index("AI_STARTED"))
|
||||
self.assertEqual(result.stdout.count("DEPS"), 1)
|
||||
|
||||
def test_backend_fatal_startup_log_stops_waiting_while_reloader_is_alive(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
log_file = Path(directory) / "backend.log"
|
||||
log_file.write_text("Application startup failed. Exiting.\n")
|
||||
result = run_shell(
|
||||
["wait_for_http", "backend_startup_failed", "log_matches"],
|
||||
f"""
|
||||
BACKEND_PID=$$
|
||||
BACKEND_LOG_FILE={log_file}
|
||||
set_wait_detail() {{ :; }}
|
||||
clear_wait_spinner() {{ :; }}
|
||||
http_ok() {{ echo HTTP_PROBE; return 1; }}
|
||||
animate_wait_spinner() {{ echo WAIT; }}
|
||||
""",
|
||||
"wait_for_http http://localhost:8000/health 60 2 backend backend_startup_failed",
|
||||
)
|
||||
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
|
||||
self.assertNotIn("WAIT", result.stdout)
|
||||
self.assertNotIn("HTTP_PROBE", result.stdout)
|
||||
|
||||
def test_readiness_without_failure_probe_still_waits_for_slow_healthy_services(self) -> None:
|
||||
result = run_shell(
|
||||
["wait_for_http"],
|
||||
"""
|
||||
probes=0
|
||||
set_wait_detail() { :; }
|
||||
clear_wait_spinner() { :; }
|
||||
finish_wait_spinner() { :; }
|
||||
format_wait_elapsed_seconds() { echo 0; }
|
||||
http_ok() { probes=$((probes + 1)); [ $probes -eq 3 ]; }
|
||||
animate_wait_spinner() { echo WAIT; }
|
||||
""",
|
||||
"wait_for_http http://localhost:8010/health 10 2 provider",
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.count("WAIT"), 2)
|
||||
|
||||
def test_backend_worker_import_failure_is_distinct_from_optional_collector_errors(self) -> None:
|
||||
for contents, expected_status in (
|
||||
("Process SpawnProcess-1:\nModuleNotFoundError: missing_app\n", 0),
|
||||
("Collector unavailable\nImportError: optional_dependency\n", 1),
|
||||
):
|
||||
with self.subTest(contents=contents), tempfile.TemporaryDirectory() as directory:
|
||||
log_file = Path(directory) / "backend.log"
|
||||
log_file.write_text(contents)
|
||||
result = run_shell(
|
||||
["backend_startup_failed", "log_matches"],
|
||||
f"BACKEND_PID=$$\nBACKEND_LOG_FILE={log_file}",
|
||||
"backend_startup_failed",
|
||||
)
|
||||
self.assertEqual(result.returncode, expected_status, result.stderr)
|
||||
|
||||
def test_existing_container_gets_current_compose_configuration(self) -> None:
|
||||
for function in ("start_database_services", "start_postgres_service"):
|
||||
with self.subTest(function=function):
|
||||
|
||||
18
uv.lock
generated
18
uv.lock
generated
@@ -757,7 +757,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "planet"
|
||||
version = "0.74.3"
|
||||
version = "0.74.5"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -785,6 +785,14 @@ dependencies = [
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
aiprovider = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
dev = [
|
||||
{ name = "black" },
|
||||
{ name = "pytest" },
|
||||
@@ -819,6 +827,14 @@ requires-dist = [
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
aiprovider = [
|
||||
{ name = "fastapi", specifier = "==0.128.0" },
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
{ name = "pydantic", specifier = "==2.12.5" },
|
||||
{ name = "pydantic-settings", specifier = "==2.12.0" },
|
||||
{ name = "python-dotenv", specifier = "==1.2.1" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.40.0" },
|
||||
]
|
||||
dev = [
|
||||
{ name = "black", specifier = ">=24.0.0" },
|
||||
{ name = "pytest", specifier = ">=7.4.0" },
|
||||
|
||||
Reference in New Issue
Block a user