release: bump version to 0.51.0
This commit is contained in:
@@ -20,7 +20,11 @@ from app.services.bgp_collector_locations import (
|
|||||||
)
|
)
|
||||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||||
from app.services.ai_client import get_ai_provider_client
|
from app.services.ai_client import get_ai_provider_client
|
||||||
from app.services.location.llm_fallback import collect_llm_location_fallback_candidate
|
from app.api.v1.settings import get_web_search_client
|
||||||
|
from app.services.location.llm_fallback import (
|
||||||
|
collect_llm_location_fallback_candidate,
|
||||||
|
collect_location_search_evidence,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -320,21 +324,33 @@ async def collect_bgp_collector_location(
|
|||||||
country=country,
|
country=country,
|
||||||
operator=operator,
|
operator=operator,
|
||||||
)
|
)
|
||||||
|
llm_result = None
|
||||||
try:
|
try:
|
||||||
|
web_search_client = await get_web_search_client(db)
|
||||||
|
search_result = await collect_location_search_evidence(
|
||||||
|
web_search_client=web_search_client,
|
||||||
|
query=query,
|
||||||
|
entity_type="bgp_collector",
|
||||||
|
)
|
||||||
|
attempted_queries = [*attempted_queries, *search_result.attempted_queries]
|
||||||
|
if not search_result.evidence:
|
||||||
|
llm_failure_reason = search_result.failure_reason
|
||||||
|
raise RuntimeError(search_result.failure_reason or "no WebSearch evidence")
|
||||||
provider_client = await get_ai_provider_client(db)
|
provider_client = await get_ai_provider_client(db)
|
||||||
llm_result = await collect_llm_location_fallback_candidate(
|
llm_result = await collect_llm_location_fallback_candidate(
|
||||||
provider_client=provider_client,
|
provider_client=provider_client,
|
||||||
query=query,
|
query=query,
|
||||||
entity_type="bgp_collector",
|
entity_type="bgp_collector",
|
||||||
attempted_queries=attempted_queries,
|
attempted_queries=attempted_queries,
|
||||||
|
search_evidence=search_result.evidence,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
llm_result = None
|
if llm_failure_reason is None:
|
||||||
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
|
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
|
||||||
attempted_queries = [
|
attempted_queries = [
|
||||||
*attempted_queries,
|
*attempted_queries,
|
||||||
f"llm_factcheck:bgp_collector:{collector_id or 'unknown'}",
|
f"llm_factcheck:bgp_collector:{collector_id or 'unknown'}",
|
||||||
]
|
]
|
||||||
if llm_result is not None:
|
if llm_result is not None:
|
||||||
attempted_queries = [*attempted_queries, *llm_result.attempted_queries]
|
attempted_queries = [*attempted_queries, *llm_result.attempted_queries]
|
||||||
candidates = llm_result.candidates
|
candidates = llm_result.candidates
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -38,6 +39,16 @@ from app.services.datasource_connectivity import (
|
|||||||
save_connectivity_success,
|
save_connectivity_success,
|
||||||
)
|
)
|
||||||
from app.services.ai_client import AIProviderClient, get_ai_provider_client
|
from app.services.ai_client import AIProviderClient, get_ai_provider_client
|
||||||
|
from app.services.ai_tools.schemas import WebSearchConfig, WebSearchProviderConfig
|
||||||
|
from app.services.ai_tools.web_search import (
|
||||||
|
WebSearchClient,
|
||||||
|
WebSearchConfigurationError,
|
||||||
|
WebSearchError,
|
||||||
|
get_web_search_provider_preset,
|
||||||
|
list_web_search_provider_presets,
|
||||||
|
normalize_web_search_provider,
|
||||||
|
provider_defaults as web_search_provider_defaults,
|
||||||
|
)
|
||||||
from app.services.llm_provider_catalog import (
|
from app.services.llm_provider_catalog import (
|
||||||
FALLBACK_LLM_PROVIDER_PRESETS,
|
FALLBACK_LLM_PROVIDER_PRESETS,
|
||||||
get_fallback_llm_provider_preset,
|
get_fallback_llm_provider_preset,
|
||||||
@@ -78,7 +89,12 @@ DEFAULT_SETTINGS = {
|
|||||||
"providers": {},
|
"providers": {},
|
||||||
"timeout_seconds": 60,
|
"timeout_seconds": 60,
|
||||||
"retry_attempts": 2,
|
"retry_attempts": 2,
|
||||||
}
|
},
|
||||||
|
"web_search": {
|
||||||
|
"enabled": False,
|
||||||
|
"default_provider": "tavily",
|
||||||
|
"providers": {},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,9 +177,31 @@ class BarentsWatchIntegrationUpdate(BaseModel):
|
|||||||
clear_client_secret: bool = False
|
clear_client_secret: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class WebSearchIntegrationUpdate(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
default_provider: Optional[str] = None
|
||||||
|
provider: str = Field(default="tavily", max_length=80)
|
||||||
|
base_url: str = Field(default="", max_length=500)
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
max_results: int = Field(default=5, ge=1, le=20)
|
||||||
|
timeout_seconds: int = Field(default=20, ge=3, le=120)
|
||||||
|
endpoint_path: str = Field(default="", max_length=200)
|
||||||
|
search_depth: str = Field(default="basic", max_length=40)
|
||||||
|
engine: str = Field(default="google", max_length=80)
|
||||||
|
include_answer: bool = False
|
||||||
|
include_raw_content: bool = False
|
||||||
|
include_text: bool = False
|
||||||
|
categories: str = Field(default="general", max_length=120)
|
||||||
|
engines: list[str] = Field(default_factory=list)
|
||||||
|
search_path: str = Field(default="", max_length=200)
|
||||||
|
scrape_path: str = Field(default="", max_length=200)
|
||||||
|
scrape_formats: list[str] = Field(default_factory=lambda: ["markdown"])
|
||||||
|
|
||||||
|
|
||||||
class ExternalIntegrationsUpdate(BaseModel):
|
class ExternalIntegrationsUpdate(BaseModel):
|
||||||
ai_provider: AIProviderIntegrationUpdate
|
ai_provider: AIProviderIntegrationUpdate
|
||||||
barentswatch: BarentsWatchIntegrationUpdate
|
barentswatch: BarentsWatchIntegrationUpdate
|
||||||
|
web_search: WebSearchIntegrationUpdate | None = None
|
||||||
|
|
||||||
|
|
||||||
def merge_with_defaults(category: str, payload: Optional[dict]) -> dict:
|
def merge_with_defaults(category: str, payload: Optional[dict]) -> dict:
|
||||||
@@ -217,6 +255,11 @@ async def save_setting_payload(db: AsyncSession, category: str, payload: dict) -
|
|||||||
|
|
||||||
|
|
||||||
AI_PROVIDER_ENV_FILE = Path(__file__).resolve().parents[4] / "aiprovider" / ".env"
|
AI_PROVIDER_ENV_FILE = Path(__file__).resolve().parents[4] / "aiprovider" / ".env"
|
||||||
|
WEB_SEARCH_ENV_FILES = (
|
||||||
|
Path(__file__).resolve().parents[4] / ".env",
|
||||||
|
Path(__file__).resolve().parents[3] / ".env",
|
||||||
|
AI_PROVIDER_ENV_FILE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mask_secret(value: Optional[str], source: str = "") -> dict:
|
def _mask_secret(value: Optional[str], source: str = "") -> dict:
|
||||||
@@ -270,6 +313,33 @@ def _resolve_env_secret(*names: str) -> tuple[str, str]:
|
|||||||
return "", ""
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
|
def _read_web_search_env_files() -> dict[str, str]:
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for path in WEB_SEARCH_ENV_FILES:
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
values.update({
|
||||||
|
key: str(value)
|
||||||
|
for key, value in dotenv_values(path).items()
|
||||||
|
if value is not None
|
||||||
|
})
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_web_search_env_secret(*names: str) -> tuple[str, str]:
|
||||||
|
env_file_values = _read_web_search_env_files()
|
||||||
|
for name in names:
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
value = env_file_values.get(name)
|
||||||
|
if value:
|
||||||
|
return value, "env_file"
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value:
|
||||||
|
return value, "env"
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
def _provider_defaults(provider: str) -> dict:
|
def _provider_defaults(provider: str) -> dict:
|
||||||
preset = _get_provider_preset(provider)
|
preset = _get_provider_preset(provider)
|
||||||
return {
|
return {
|
||||||
@@ -438,6 +508,160 @@ def _runtime_config_from_ai_payload(ai_payload: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _web_search_provider_defaults(provider: str) -> dict:
|
||||||
|
return web_search_provider_defaults(provider).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_web_search_payload(web_search_payload: dict | None) -> dict:
|
||||||
|
raw = dict(web_search_payload or {})
|
||||||
|
default_provider = normalize_web_search_provider(
|
||||||
|
raw.get("default_provider") or raw.get("provider")
|
||||||
|
)
|
||||||
|
providers = {
|
||||||
|
normalize_web_search_provider(provider): dict(config or {})
|
||||||
|
for provider, config in (raw.get("providers") or {}).items()
|
||||||
|
if provider
|
||||||
|
}
|
||||||
|
legacy_fields = {
|
||||||
|
key: raw.get(key)
|
||||||
|
for key in (
|
||||||
|
"base_url",
|
||||||
|
"api_key",
|
||||||
|
"max_results",
|
||||||
|
"timeout_seconds",
|
||||||
|
"endpoint_path",
|
||||||
|
"search_depth",
|
||||||
|
"engine",
|
||||||
|
"include_answer",
|
||||||
|
"include_raw_content",
|
||||||
|
"include_text",
|
||||||
|
"categories",
|
||||||
|
"engines",
|
||||||
|
"search_path",
|
||||||
|
"scrape_path",
|
||||||
|
"scrape_formats",
|
||||||
|
)
|
||||||
|
if raw.get(key) not in (None, "")
|
||||||
|
}
|
||||||
|
if legacy_fields:
|
||||||
|
providers[default_provider] = {
|
||||||
|
**providers.get(default_provider, {}),
|
||||||
|
**legacy_fields,
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized_providers: dict[str, dict] = {}
|
||||||
|
for provider, config in providers.items():
|
||||||
|
provider_id = normalize_web_search_provider(provider)
|
||||||
|
normalized_providers[provider_id] = {
|
||||||
|
**_web_search_provider_defaults(provider_id),
|
||||||
|
**dict(config or {}),
|
||||||
|
"provider": provider_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if default_provider not in normalized_providers:
|
||||||
|
normalized_providers[default_provider] = _web_search_provider_defaults(default_provider)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": bool(raw.get("enabled", False)),
|
||||||
|
"default_provider": default_provider,
|
||||||
|
"providers": normalized_providers,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_web_search_api_key(provider: str, provider_config: dict) -> tuple[str, str]:
|
||||||
|
saved_key = provider_config.get("api_key") or ""
|
||||||
|
if saved_key:
|
||||||
|
return str(saved_key), "runtime"
|
||||||
|
preset = get_web_search_provider_preset(provider)
|
||||||
|
return _resolve_web_search_env_secret(preset.get("api_key_env") or "", "WEB_SEARCH_API_KEY")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_web_search_payload(
|
||||||
|
current_payload: dict,
|
||||||
|
update: WebSearchIntegrationUpdate | None,
|
||||||
|
) -> dict:
|
||||||
|
current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {})
|
||||||
|
if update is None:
|
||||||
|
return current_web_search
|
||||||
|
provider_id = normalize_web_search_provider(update.default_provider or update.provider)
|
||||||
|
current_providers = {
|
||||||
|
provider: dict(config or {})
|
||||||
|
for provider, config in current_web_search.get("providers", {}).items()
|
||||||
|
}
|
||||||
|
current_provider = current_providers.get(provider_id) or _web_search_provider_defaults(provider_id)
|
||||||
|
current_key, current_key_source = _resolve_web_search_api_key(provider_id, current_provider)
|
||||||
|
current_key_preview = _mask_secret(current_key, current_key_source)["preview"]
|
||||||
|
provider_payload = {
|
||||||
|
**_web_search_provider_defaults(provider_id),
|
||||||
|
**current_provider,
|
||||||
|
"provider": provider_id,
|
||||||
|
"base_url": update.base_url.strip()
|
||||||
|
or current_provider.get("base_url")
|
||||||
|
or _web_search_provider_defaults(provider_id).get("base_url")
|
||||||
|
or "",
|
||||||
|
"max_results": update.max_results,
|
||||||
|
"timeout_seconds": update.timeout_seconds,
|
||||||
|
"endpoint_path": update.endpoint_path.strip() or current_provider.get("endpoint_path") or "",
|
||||||
|
"search_depth": update.search_depth.strip() or "basic",
|
||||||
|
"engine": update.engine.strip() or "google",
|
||||||
|
"include_answer": update.include_answer,
|
||||||
|
"include_raw_content": update.include_raw_content,
|
||||||
|
"include_text": update.include_text,
|
||||||
|
"categories": update.categories.strip() or "general",
|
||||||
|
"engines": update.engines,
|
||||||
|
"search_path": update.search_path.strip() or current_provider.get("search_path") or "",
|
||||||
|
"scrape_path": update.scrape_path.strip() or current_provider.get("scrape_path") or "",
|
||||||
|
"scrape_formats": update.scrape_formats or ["markdown"],
|
||||||
|
}
|
||||||
|
if not _is_secret_placeholder(update.api_key, current_key_preview):
|
||||||
|
provider_payload["api_key"] = str(update.api_key).strip()
|
||||||
|
elif current_provider.get("api_key"):
|
||||||
|
provider_payload["api_key"] = current_provider.get("api_key") or ""
|
||||||
|
else:
|
||||||
|
provider_payload["api_key"] = ""
|
||||||
|
current_providers[provider_id] = provider_payload
|
||||||
|
return {
|
||||||
|
"enabled": update.enabled,
|
||||||
|
"default_provider": provider_id,
|
||||||
|
"providers": current_providers,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_config_from_web_search_payload(web_search_payload: dict) -> WebSearchConfig:
|
||||||
|
normalized = _normalize_web_search_payload(web_search_payload)
|
||||||
|
provider_id = normalized["default_provider"]
|
||||||
|
provider_config = normalized["providers"].get(provider_id) or _web_search_provider_defaults(provider_id)
|
||||||
|
api_key, _source = _resolve_web_search_api_key(provider_id, provider_config)
|
||||||
|
provider_models = {
|
||||||
|
provider: WebSearchProviderConfig(**{
|
||||||
|
**config,
|
||||||
|
"api_key": (
|
||||||
|
api_key if provider == provider_id else _resolve_web_search_api_key(provider, config)[0]
|
||||||
|
),
|
||||||
|
})
|
||||||
|
for provider, config in normalized["providers"].items()
|
||||||
|
}
|
||||||
|
return WebSearchConfig(
|
||||||
|
enabled=normalized["enabled"],
|
||||||
|
default_provider=provider_id,
|
||||||
|
provider=provider_id,
|
||||||
|
providers=provider_models,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_runtime_web_search_config(db: AsyncSession) -> WebSearchConfig:
|
||||||
|
runtime_record = await get_setting_record(db, "external_integrations")
|
||||||
|
payload = merge_with_defaults(
|
||||||
|
"external_integrations",
|
||||||
|
runtime_record.payload if runtime_record else None,
|
||||||
|
)
|
||||||
|
return _runtime_config_from_web_search_payload(payload.get("web_search") or {})
|
||||||
|
|
||||||
|
|
||||||
|
async def get_web_search_client(db: AsyncSession) -> WebSearchClient:
|
||||||
|
return WebSearchClient(await get_runtime_web_search_config(db))
|
||||||
|
|
||||||
|
|
||||||
async def get_runtime_ai_provider_config(db: AsyncSession) -> dict:
|
async def get_runtime_ai_provider_config(db: AsyncSession) -> dict:
|
||||||
runtime_record = await get_setting_record(db, "external_integrations")
|
runtime_record = await get_setting_record(db, "external_integrations")
|
||||||
payload = merge_with_defaults(
|
payload = merge_with_defaults(
|
||||||
@@ -459,6 +683,7 @@ async def serialize_external_integrations(db: AsyncSession) -> dict:
|
|||||||
runtime_setting.payload if runtime_setting else None,
|
runtime_setting.payload if runtime_setting else None,
|
||||||
)
|
)
|
||||||
normalized_ai = _normalize_ai_provider_payload(raw_payload.get("ai_provider") or {})
|
normalized_ai = _normalize_ai_provider_payload(raw_payload.get("ai_provider") or {})
|
||||||
|
normalized_web_search = _normalize_web_search_payload(raw_payload.get("web_search") or {})
|
||||||
default_provider = normalized_ai["default_provider"]
|
default_provider = normalized_ai["default_provider"]
|
||||||
providers_payload: dict[str, dict] = {}
|
providers_payload: dict[str, dict] = {}
|
||||||
for provider in sorted({
|
for provider in sorted({
|
||||||
@@ -480,6 +705,32 @@ async def serialize_external_integrations(db: AsyncSession) -> dict:
|
|||||||
"source": "runtime" if provider_config.get("api_key") else (api_key_source or "preset"),
|
"source": "runtime" if provider_config.get("api_key") else (api_key_source or "preset"),
|
||||||
}
|
}
|
||||||
display_llm_config = providers_payload.get(default_provider) or _provider_defaults(default_provider)
|
display_llm_config = providers_payload.get(default_provider) or _provider_defaults(default_provider)
|
||||||
|
web_search_providers_payload: dict[str, dict] = {}
|
||||||
|
for provider in sorted({
|
||||||
|
*[preset["provider"] for preset in list_web_search_provider_presets()],
|
||||||
|
*normalized_web_search["providers"].keys(),
|
||||||
|
normalized_web_search["default_provider"],
|
||||||
|
}):
|
||||||
|
provider_id = normalize_web_search_provider(provider)
|
||||||
|
provider_config = (
|
||||||
|
normalized_web_search["providers"].get(provider_id)
|
||||||
|
or _web_search_provider_defaults(provider_id)
|
||||||
|
)
|
||||||
|
api_key, api_key_source = _resolve_web_search_api_key(provider_id, provider_config)
|
||||||
|
web_search_providers_payload[provider_id] = {
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in provider_config.items()
|
||||||
|
if key != "api_key"
|
||||||
|
},
|
||||||
|
"provider": provider_id,
|
||||||
|
"api_key": _mask_secret(api_key, api_key_source),
|
||||||
|
"source": "runtime" if provider_config.get("api_key") else (api_key_source or "preset"),
|
||||||
|
}
|
||||||
|
display_web_search_config = (
|
||||||
|
web_search_providers_payload.get(normalized_web_search["default_provider"])
|
||||||
|
or _web_search_provider_defaults(normalized_web_search["default_provider"])
|
||||||
|
)
|
||||||
barentswatch_record = await get_barentswatch_config_record(db)
|
barentswatch_record = await get_barentswatch_config_record(db)
|
||||||
barentswatch_auth = barentswatch_record.auth_config if barentswatch_record else {}
|
barentswatch_auth = barentswatch_record.auth_config if barentswatch_record else {}
|
||||||
barentswatch_auth = barentswatch_auth or {}
|
barentswatch_auth = barentswatch_auth or {}
|
||||||
@@ -509,6 +760,28 @@ async def serialize_external_integrations(db: AsyncSession) -> dict:
|
|||||||
),
|
),
|
||||||
"source": resolved_barentswatch.credential_source,
|
"source": resolved_barentswatch.credential_source,
|
||||||
},
|
},
|
||||||
|
"web_search": {
|
||||||
|
"enabled": normalized_web_search["enabled"],
|
||||||
|
"default_provider": normalized_web_search["default_provider"],
|
||||||
|
"provider": normalized_web_search["default_provider"],
|
||||||
|
"base_url": display_web_search_config.get("base_url") or "",
|
||||||
|
"api_key": display_web_search_config.get("api_key") or _mask_secret(None),
|
||||||
|
"providers": web_search_providers_payload,
|
||||||
|
"max_results": int(display_web_search_config.get("max_results") or 5),
|
||||||
|
"timeout_seconds": int(display_web_search_config.get("timeout_seconds") or 20),
|
||||||
|
"endpoint_path": display_web_search_config.get("endpoint_path") or "",
|
||||||
|
"search_depth": display_web_search_config.get("search_depth") or "basic",
|
||||||
|
"engine": display_web_search_config.get("engine") or "google",
|
||||||
|
"include_answer": bool(display_web_search_config.get("include_answer", False)),
|
||||||
|
"include_raw_content": bool(display_web_search_config.get("include_raw_content", False)),
|
||||||
|
"include_text": bool(display_web_search_config.get("include_text", False)),
|
||||||
|
"categories": display_web_search_config.get("categories") or "general",
|
||||||
|
"engines": display_web_search_config.get("engines") or [],
|
||||||
|
"search_path": display_web_search_config.get("search_path") or "",
|
||||||
|
"scrape_path": display_web_search_config.get("scrape_path") or "",
|
||||||
|
"scrape_formats": display_web_search_config.get("scrape_formats") or ["markdown"],
|
||||||
|
"source": "runtime" if runtime_setting else "env",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -518,8 +791,13 @@ async def save_external_integrations_payload(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
current_payload = await get_setting_payload(db, "external_integrations")
|
current_payload = await get_setting_payload(db, "external_integrations")
|
||||||
ai_payload = _build_ai_provider_payload(current_payload, update.ai_provider)
|
ai_payload = _build_ai_provider_payload(current_payload, update.ai_provider)
|
||||||
|
web_search_payload = _build_web_search_payload(current_payload, update.web_search)
|
||||||
|
|
||||||
await save_setting_payload(db, "external_integrations", {"ai_provider": ai_payload})
|
await save_setting_payload(
|
||||||
|
db,
|
||||||
|
"external_integrations",
|
||||||
|
{"ai_provider": ai_payload, "web_search": web_search_payload},
|
||||||
|
)
|
||||||
|
|
||||||
default_endpoint = get_data_sources_config().get_yaml_url("barentswatch_vessels")
|
default_endpoint = get_data_sources_config().get_yaml_url("barentswatch_vessels")
|
||||||
barentswatch_record = await get_barentswatch_config_record(db)
|
barentswatch_record = await get_barentswatch_config_record(db)
|
||||||
@@ -754,7 +1032,12 @@ async def connect_ai_provider_integration(
|
|||||||
constraints=["回复尽量简短。"],
|
constraints=["回复尽量简短。"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await save_setting_payload(db, "external_integrations", {"ai_provider": draft_ai_payload})
|
current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {})
|
||||||
|
await save_setting_payload(
|
||||||
|
db,
|
||||||
|
"external_integrations",
|
||||||
|
{"ai_provider": draft_ai_payload, "web_search": current_web_search},
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"connected": True,
|
"connected": True,
|
||||||
@@ -799,6 +1082,74 @@ async def reveal_ai_provider_secrets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/integrations/web-search/presets")
|
||||||
|
async def get_web_search_presets(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return {"data": list_web_search_provider_presets()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/integrations/web-search/secrets")
|
||||||
|
async def reveal_web_search_secrets(
|
||||||
|
provider: str = Query(default=""),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
current_payload = await get_setting_payload(db, "external_integrations")
|
||||||
|
web_search_payload = _normalize_web_search_payload(current_payload.get("web_search") or {})
|
||||||
|
provider_id = normalize_web_search_provider(provider or web_search_payload["default_provider"])
|
||||||
|
provider_config = (
|
||||||
|
web_search_payload["providers"].get(provider_id)
|
||||||
|
or _web_search_provider_defaults(provider_id)
|
||||||
|
)
|
||||||
|
api_key, api_key_source = _resolve_web_search_api_key(provider_id, provider_config)
|
||||||
|
return {
|
||||||
|
"provider": provider_id,
|
||||||
|
"api_key": api_key,
|
||||||
|
"api_key_source": api_key_source,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/integrations/web-search/connect")
|
||||||
|
async def connect_web_search_integration(
|
||||||
|
payload: WebSearchIntegrationUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
current_payload = await get_setting_payload(db, "external_integrations")
|
||||||
|
draft_web_search_payload = _build_web_search_payload(current_payload, payload)
|
||||||
|
runtime_config = _runtime_config_from_web_search_payload(draft_web_search_payload)
|
||||||
|
client = WebSearchClient(runtime_config)
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = await client.test_connection()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"connected": True,
|
||||||
|
"message": "WebSearch 连接成功。",
|
||||||
|
"provider": runtime_config.default_provider,
|
||||||
|
"results": [item.model_dump(mode="json") for item in results[:3]],
|
||||||
|
}
|
||||||
|
except WebSearchConfigurationError as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"connected": False,
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
except WebSearchError as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"connected": False,
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"connected": False,
|
||||||
|
"message": f"WebSearch 连接测试失败: {exc}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/credential-guides/{provider}")
|
@router.get("/credential-guides/{provider}")
|
||||||
async def read_credential_guide(
|
async def read_credential_guide(
|
||||||
provider: str,
|
provider: str,
|
||||||
@@ -819,7 +1170,15 @@ async def generate_provider_credential_guide(
|
|||||||
ai_client: AIProviderClient = Depends(get_ai_provider_client),
|
ai_client: AIProviderClient = Depends(get_ai_provider_client),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
return {"guide": await generate_credential_guide(db, provider, ai_client)}
|
web_search_client = await get_web_search_client(db)
|
||||||
|
return {
|
||||||
|
"guide": await generate_credential_guide(
|
||||||
|
db,
|
||||||
|
provider,
|
||||||
|
ai_client,
|
||||||
|
web_search_client,
|
||||||
|
)
|
||||||
|
}
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,12 @@ from app.services.compute_center_locations import (
|
|||||||
upsert_compute_center_location,
|
upsert_compute_center_location,
|
||||||
)
|
)
|
||||||
from app.services.ai_client import get_ai_provider_client
|
from app.services.ai_client import get_ai_provider_client
|
||||||
|
from app.api.v1.settings import get_web_search_client
|
||||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||||
from app.services.location.llm_fallback import collect_llm_location_fallback_candidate
|
from app.services.location.llm_fallback import (
|
||||||
|
collect_llm_location_fallback_candidate,
|
||||||
|
collect_location_search_evidence,
|
||||||
|
)
|
||||||
from app.services.persistent_logs import record_system_log
|
from app.services.persistent_logs import record_system_log
|
||||||
from app.services.vessel_ais_aggregation import (
|
from app.services.vessel_ais_aggregation import (
|
||||||
build_field_conflict_candidates,
|
build_field_conflict_candidates,
|
||||||
@@ -1879,21 +1883,33 @@ async def collect_compute_center_location(
|
|||||||
city=city,
|
city=city,
|
||||||
country=country,
|
country=country,
|
||||||
)
|
)
|
||||||
|
llm_result = None
|
||||||
try:
|
try:
|
||||||
|
web_search_client = await get_web_search_client(db)
|
||||||
|
search_result = await collect_location_search_evidence(
|
||||||
|
web_search_client=web_search_client,
|
||||||
|
query=query,
|
||||||
|
entity_type="compute_center",
|
||||||
|
)
|
||||||
|
attempted_queries = [*attempted_queries, *search_result.attempted_queries]
|
||||||
|
if not search_result.evidence:
|
||||||
|
llm_failure_reason = search_result.failure_reason
|
||||||
|
raise RuntimeError(search_result.failure_reason or "no WebSearch evidence")
|
||||||
provider_client = await get_ai_provider_client(db)
|
provider_client = await get_ai_provider_client(db)
|
||||||
llm_result = await collect_llm_location_fallback_candidate(
|
llm_result = await collect_llm_location_fallback_candidate(
|
||||||
provider_client=provider_client,
|
provider_client=provider_client,
|
||||||
query=query,
|
query=query,
|
||||||
entity_type="compute_center",
|
entity_type="compute_center",
|
||||||
attempted_queries=attempted_queries,
|
attempted_queries=attempted_queries,
|
||||||
|
search_evidence=search_result.evidence,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
llm_result = None
|
if llm_failure_reason is None:
|
||||||
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
|
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
|
||||||
attempted_queries = [
|
attempted_queries = [
|
||||||
*attempted_queries,
|
*attempted_queries,
|
||||||
f"llm_factcheck:compute_center:{name or source_id or 'unknown'}",
|
f"llm_factcheck:compute_center:{name or source_id or 'unknown'}",
|
||||||
]
|
]
|
||||||
if llm_result is not None:
|
if llm_result is not None:
|
||||||
attempted_queries = [*attempted_queries, *llm_result.attempted_queries]
|
attempted_queries = [*attempted_queries, *llm_result.attempted_queries]
|
||||||
candidates = llm_result.candidates
|
candidates = llm_result.candidates
|
||||||
|
|||||||
7
backend/app/services/ai_tools/__init__.py
Normal file
7
backend/app/services/ai_tools/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""Backend-owned AI tool services.
|
||||||
|
|
||||||
|
The services in this package are business tools used by Planet's backend
|
||||||
|
orchestrators. They intentionally live outside ``aiprovider`` so model transport
|
||||||
|
stays separate from evidence collection and domain policy.
|
||||||
|
"""
|
||||||
|
|
||||||
48
backend/app/services/ai_tools/evidence_store.py
Normal file
48
backend/app/services/ai_tools/evidence_store.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from app.services.ai_tools.schemas import FetchedEvidence, SearchEvidence
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_content_hash(text: str) -> str:
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_search_evidence(items: Iterable[SearchEvidence], *, limit: int = 5) -> list[dict]:
|
||||||
|
normalized: list[dict] = []
|
||||||
|
seen_urls: set[str] = set()
|
||||||
|
for item in items:
|
||||||
|
if not item.url or item.url in seen_urls:
|
||||||
|
continue
|
||||||
|
seen_urls.add(item.url)
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"title": item.title,
|
||||||
|
"url": item.url,
|
||||||
|
"snippet": item.snippet,
|
||||||
|
"content": item.compact_text(),
|
||||||
|
"score": item.score,
|
||||||
|
"source_provider": item.source_provider,
|
||||||
|
"retrieved_at": item.retrieved_at.isoformat(),
|
||||||
|
"metadata": item.metadata,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(normalized) >= limit:
|
||||||
|
break
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_fetched_evidence(item: FetchedEvidence, *, text_limit: int = 1200) -> dict:
|
||||||
|
text = " ".join(item.text.split())[:text_limit]
|
||||||
|
return {
|
||||||
|
"title": item.title,
|
||||||
|
"url": item.final_url or item.url,
|
||||||
|
"text": text,
|
||||||
|
"content_hash": item.content_hash or evidence_content_hash(item.text),
|
||||||
|
"extractor": item.extractor,
|
||||||
|
"retrieved_at": item.retrieved_at.isoformat(),
|
||||||
|
"metadata": item.metadata,
|
||||||
|
}
|
||||||
|
|
||||||
63
backend/app/services/ai_tools/schemas.py
Normal file
63
backend/app/services/ai_tools/schemas.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SearchEvidence(BaseModel):
|
||||||
|
title: str = ""
|
||||||
|
url: str = ""
|
||||||
|
snippet: str = ""
|
||||||
|
content: str = ""
|
||||||
|
score: float | None = None
|
||||||
|
source_provider: str = ""
|
||||||
|
retrieved_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
def compact_text(self, limit: int = 700) -> str:
|
||||||
|
text = " ".join((self.content or self.snippet or "").split())
|
||||||
|
return text[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
class FetchedEvidence(BaseModel):
|
||||||
|
url: str
|
||||||
|
final_url: str = ""
|
||||||
|
title: str = ""
|
||||||
|
text: str = ""
|
||||||
|
content_hash: str = ""
|
||||||
|
extractor: str = "basic_html"
|
||||||
|
retrieved_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSearchProviderConfig(BaseModel):
|
||||||
|
provider: str = "tavily"
|
||||||
|
base_url: str = ""
|
||||||
|
api_key: str = ""
|
||||||
|
max_results: int = Field(default=5, ge=1, le=20)
|
||||||
|
timeout_seconds: int = Field(default=20, ge=3, le=120)
|
||||||
|
endpoint_path: str = ""
|
||||||
|
search_depth: str = "basic"
|
||||||
|
engine: str = "google"
|
||||||
|
include_answer: bool = False
|
||||||
|
include_raw_content: bool = False
|
||||||
|
include_text: bool = False
|
||||||
|
categories: str = "general"
|
||||||
|
engines: list[str] = Field(default_factory=list)
|
||||||
|
search_path: str = ""
|
||||||
|
scrape_path: str = ""
|
||||||
|
scrape_formats: list[str] = Field(default_factory=lambda: ["markdown"])
|
||||||
|
|
||||||
|
|
||||||
|
class WebSearchConfig(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
default_provider: str = "tavily"
|
||||||
|
provider: str = "tavily"
|
||||||
|
providers: dict[str, WebSearchProviderConfig] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_provider_config(self) -> WebSearchProviderConfig:
|
||||||
|
return self.providers.get(self.default_provider) or self.providers.get(self.provider) or WebSearchProviderConfig(provider=self.default_provider or self.provider)
|
||||||
|
|
||||||
56
backend/app/services/ai_tools/web_fetch.py
Normal file
56
backend/app/services/ai_tools/web_fetch.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from app.services.ai_tools.schemas import FetchedEvidence
|
||||||
|
|
||||||
|
|
||||||
|
class WebFetchError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_title_and_text(html: str) -> tuple[str, str]:
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
for tag in soup(["script", "style", "noscript", "svg"]):
|
||||||
|
tag.decompose()
|
||||||
|
title = soup.title.get_text(" ", strip=True) if soup.title else ""
|
||||||
|
main = soup.find("main") or soup.find("article") or soup.body or soup
|
||||||
|
text = main.get_text("\n", strip=True)
|
||||||
|
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||||
|
return title, "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_url_evidence(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout_seconds: int = 20,
|
||||||
|
max_bytes: int = 1_500_000,
|
||||||
|
) -> FetchedEvidence:
|
||||||
|
if not url:
|
||||||
|
raise WebFetchError("url is required")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
follow_redirects=True,
|
||||||
|
headers={"User-Agent": "PlanetEvidenceFetcher/1.0"},
|
||||||
|
) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
content = response.content[:max_bytes]
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise WebFetchError(f"failed to fetch page: {exc}") from exc
|
||||||
|
|
||||||
|
title, text = _extract_title_and_text(content.decode(response.encoding or "utf-8", errors="ignore"))
|
||||||
|
content_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
return FetchedEvidence(
|
||||||
|
url=url,
|
||||||
|
final_url=str(response.url),
|
||||||
|
title=title,
|
||||||
|
text=text,
|
||||||
|
content_hash=content_hash,
|
||||||
|
extractor="beautifulsoup_basic",
|
||||||
|
)
|
||||||
|
|
||||||
391
backend/app/services/ai_tools/web_search.py
Normal file
391
backend/app/services/ai_tools/web_search.py
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.services.ai_tools.schemas import SearchEvidence, WebSearchConfig, WebSearchProviderConfig
|
||||||
|
|
||||||
|
|
||||||
|
WEB_SEARCH_PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
|
||||||
|
"tavily": {
|
||||||
|
"provider": "tavily",
|
||||||
|
"label": "Tavily",
|
||||||
|
"api_key_env": "TAVILY_API_KEY",
|
||||||
|
"base_url": "https://api.tavily.com",
|
||||||
|
"endpoint_path": "/search",
|
||||||
|
"max_results": 5,
|
||||||
|
"timeout_seconds": 20,
|
||||||
|
"search_depth": "basic",
|
||||||
|
"include_answer": False,
|
||||||
|
"include_raw_content": False,
|
||||||
|
},
|
||||||
|
"brave": {
|
||||||
|
"provider": "brave",
|
||||||
|
"label": "Brave Search API",
|
||||||
|
"api_key_env": "BRAVE_SEARCH_API_KEY",
|
||||||
|
"base_url": "https://api.search.brave.com",
|
||||||
|
"endpoint_path": "/res/v1/web/search",
|
||||||
|
"max_results": 5,
|
||||||
|
"timeout_seconds": 20,
|
||||||
|
},
|
||||||
|
"serpapi": {
|
||||||
|
"provider": "serpapi",
|
||||||
|
"label": "SerpAPI",
|
||||||
|
"api_key_env": "SERPAPI_API_KEY",
|
||||||
|
"base_url": "https://serpapi.com",
|
||||||
|
"endpoint_path": "/search.json",
|
||||||
|
"engine": "google",
|
||||||
|
"max_results": 5,
|
||||||
|
"timeout_seconds": 20,
|
||||||
|
},
|
||||||
|
"exa": {
|
||||||
|
"provider": "exa",
|
||||||
|
"label": "Exa",
|
||||||
|
"api_key_env": "EXA_API_KEY",
|
||||||
|
"base_url": "https://api.exa.ai",
|
||||||
|
"endpoint_path": "/search",
|
||||||
|
"max_results": 5,
|
||||||
|
"timeout_seconds": 20,
|
||||||
|
"include_text": False,
|
||||||
|
},
|
||||||
|
"firecrawl": {
|
||||||
|
"provider": "firecrawl",
|
||||||
|
"label": "Firecrawl Search / Scrape",
|
||||||
|
"api_key_env": "FIRECRAWL_API_KEY",
|
||||||
|
"base_url": "https://api.firecrawl.dev",
|
||||||
|
"search_path": "/v2/search",
|
||||||
|
"scrape_path": "/v2/scrape",
|
||||||
|
"max_results": 5,
|
||||||
|
"timeout_seconds": 30,
|
||||||
|
"scrape_formats": ["markdown"],
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"provider": "searxng",
|
||||||
|
"label": "SearXNG",
|
||||||
|
"api_key_env": "SEARXNG_API_KEY",
|
||||||
|
"base_url": "http://localhost:8080",
|
||||||
|
"endpoint_path": "/",
|
||||||
|
"max_results": 5,
|
||||||
|
"timeout_seconds": 20,
|
||||||
|
"categories": "general",
|
||||||
|
"engines": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WebSearchError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WebSearchConfigurationError(WebSearchError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_web_search_provider(provider: str | None) -> str:
|
||||||
|
return (provider or "tavily").strip().lower() or "tavily"
|
||||||
|
|
||||||
|
|
||||||
|
def get_web_search_provider_preset(provider: str) -> dict[str, Any]:
|
||||||
|
provider_id = normalize_web_search_provider(provider)
|
||||||
|
preset = WEB_SEARCH_PROVIDER_PRESETS.get(provider_id)
|
||||||
|
if not preset:
|
||||||
|
raise ValueError(f"Unsupported web search provider: {provider}")
|
||||||
|
return deepcopy(preset)
|
||||||
|
|
||||||
|
|
||||||
|
def list_web_search_provider_presets() -> list[dict[str, Any]]:
|
||||||
|
return [get_web_search_provider_preset(provider) for provider in WEB_SEARCH_PROVIDER_PRESETS]
|
||||||
|
|
||||||
|
|
||||||
|
def provider_defaults(provider: str) -> WebSearchProviderConfig:
|
||||||
|
preset = get_web_search_provider_preset(provider)
|
||||||
|
return WebSearchProviderConfig(**{
|
||||||
|
key: value
|
||||||
|
for key, value in preset.items()
|
||||||
|
if key in WebSearchProviderConfig.model_fields
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class WebSearchClient:
|
||||||
|
def __init__(self, config: WebSearchConfig) -> None:
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
*,
|
||||||
|
max_results: int | None = None,
|
||||||
|
domains: list[str] | None = None,
|
||||||
|
freshness_days: int | None = None,
|
||||||
|
) -> list[SearchEvidence]:
|
||||||
|
if not self.config.enabled:
|
||||||
|
raise WebSearchConfigurationError("WebSearch is disabled.")
|
||||||
|
provider_config = self.config.active_provider_config
|
||||||
|
provider = normalize_web_search_provider(provider_config.provider)
|
||||||
|
if provider != "searxng" and not provider_config.api_key:
|
||||||
|
raise WebSearchConfigurationError(f"{provider} API key is not configured.")
|
||||||
|
query = " ".join(str(query or "").split())
|
||||||
|
if not query:
|
||||||
|
raise WebSearchConfigurationError("search query is required.")
|
||||||
|
limit = max_results or provider_config.max_results
|
||||||
|
if provider == "tavily":
|
||||||
|
return await self._search_tavily(provider_config, query, limit, domains, freshness_days)
|
||||||
|
if provider == "brave":
|
||||||
|
return await self._search_brave(provider_config, query, limit, domains)
|
||||||
|
if provider == "serpapi":
|
||||||
|
return await self._search_serpapi(provider_config, query, limit)
|
||||||
|
if provider == "exa":
|
||||||
|
return await self._search_exa(provider_config, query, limit, domains)
|
||||||
|
if provider == "firecrawl":
|
||||||
|
return await self._search_firecrawl(provider_config, query, limit)
|
||||||
|
if provider == "searxng":
|
||||||
|
return await self._search_searxng(provider_config, query, limit, domains)
|
||||||
|
raise WebSearchConfigurationError(f"Unsupported web search provider: {provider}")
|
||||||
|
|
||||||
|
async def test_connection(self) -> list[SearchEvidence]:
|
||||||
|
return await self.search("Planet WebSearch connectivity test", max_results=1)
|
||||||
|
|
||||||
|
async def _request_json(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
provider_config: WebSearchProviderConfig,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=provider_config.timeout_seconds) as client:
|
||||||
|
response = await client.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
params=params,
|
||||||
|
json=json,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
detail = exc.response.text or exc.response.reason_phrase
|
||||||
|
raise WebSearchError(f"{provider_config.provider} request failed: {detail}") from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise WebSearchError(f"{provider_config.provider} request failed: {exc}") from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise WebSearchError(f"{provider_config.provider} returned invalid JSON") from exc
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
async def _search_tavily(
|
||||||
|
self,
|
||||||
|
config: WebSearchProviderConfig,
|
||||||
|
query: str,
|
||||||
|
max_results: int,
|
||||||
|
domains: list[str] | None,
|
||||||
|
freshness_days: int | None,
|
||||||
|
) -> list[SearchEvidence]:
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"api_key": config.api_key,
|
||||||
|
"query": query,
|
||||||
|
"max_results": max_results,
|
||||||
|
"search_depth": config.search_depth or "basic",
|
||||||
|
"include_answer": config.include_answer,
|
||||||
|
"include_raw_content": config.include_raw_content,
|
||||||
|
}
|
||||||
|
if domains:
|
||||||
|
body["include_domains"] = domains
|
||||||
|
if freshness_days:
|
||||||
|
body["days"] = freshness_days
|
||||||
|
data = await self._request_json(
|
||||||
|
"POST",
|
||||||
|
_join_url(config.base_url, config.endpoint_path or "/search"),
|
||||||
|
provider_config=config,
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
SearchEvidence(
|
||||||
|
title=str(item.get("title") or ""),
|
||||||
|
url=str(item.get("url") or ""),
|
||||||
|
snippet=str(item.get("content") or ""),
|
||||||
|
content=str(item.get("raw_content") or ""),
|
||||||
|
score=_float_or_none(item.get("score")),
|
||||||
|
source_provider="tavily",
|
||||||
|
metadata={"query": data.get("query") or query},
|
||||||
|
)
|
||||||
|
for item in data.get("results") or []
|
||||||
|
if isinstance(item, dict) and item.get("url")
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _search_brave(
|
||||||
|
self,
|
||||||
|
config: WebSearchProviderConfig,
|
||||||
|
query: str,
|
||||||
|
max_results: int,
|
||||||
|
domains: list[str] | None,
|
||||||
|
) -> list[SearchEvidence]:
|
||||||
|
search_query = query
|
||||||
|
if domains:
|
||||||
|
search_query = f"{query} " + " ".join(f"site:{domain}" for domain in domains)
|
||||||
|
data = await self._request_json(
|
||||||
|
"GET",
|
||||||
|
_join_url(config.base_url, config.endpoint_path or "/res/v1/web/search"),
|
||||||
|
provider_config=config,
|
||||||
|
headers={"X-Subscription-Token": config.api_key},
|
||||||
|
params={"q": search_query, "count": max_results},
|
||||||
|
)
|
||||||
|
results = (data.get("web") or {}).get("results") or []
|
||||||
|
return [
|
||||||
|
SearchEvidence(
|
||||||
|
title=str(item.get("title") or ""),
|
||||||
|
url=str(item.get("url") or ""),
|
||||||
|
snippet=str(item.get("description") or ""),
|
||||||
|
source_provider="brave",
|
||||||
|
metadata={"age": item.get("age")},
|
||||||
|
)
|
||||||
|
for item in results
|
||||||
|
if isinstance(item, dict) and item.get("url")
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _search_serpapi(
|
||||||
|
self,
|
||||||
|
config: WebSearchProviderConfig,
|
||||||
|
query: str,
|
||||||
|
max_results: int,
|
||||||
|
) -> list[SearchEvidence]:
|
||||||
|
data = await self._request_json(
|
||||||
|
"GET",
|
||||||
|
_join_url(config.base_url, config.endpoint_path or "/search.json"),
|
||||||
|
provider_config=config,
|
||||||
|
params={
|
||||||
|
"api_key": config.api_key,
|
||||||
|
"engine": config.engine or "google",
|
||||||
|
"q": query,
|
||||||
|
"num": max_results,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
SearchEvidence(
|
||||||
|
title=str(item.get("title") or ""),
|
||||||
|
url=str(item.get("link") or ""),
|
||||||
|
snippet=str(item.get("snippet") or ""),
|
||||||
|
source_provider="serpapi",
|
||||||
|
metadata={"position": item.get("position")},
|
||||||
|
)
|
||||||
|
for item in data.get("organic_results") or []
|
||||||
|
if isinstance(item, dict) and item.get("link")
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _search_exa(
|
||||||
|
self,
|
||||||
|
config: WebSearchProviderConfig,
|
||||||
|
query: str,
|
||||||
|
max_results: int,
|
||||||
|
domains: list[str] | None,
|
||||||
|
) -> list[SearchEvidence]:
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"query": query,
|
||||||
|
"numResults": max_results,
|
||||||
|
}
|
||||||
|
if domains:
|
||||||
|
body["includeDomains"] = domains
|
||||||
|
if config.include_text:
|
||||||
|
body["contents"] = {"text": True}
|
||||||
|
data = await self._request_json(
|
||||||
|
"POST",
|
||||||
|
_join_url(config.base_url, config.endpoint_path or "/search"),
|
||||||
|
provider_config=config,
|
||||||
|
headers={"Authorization": f"Bearer {config.api_key}"},
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
SearchEvidence(
|
||||||
|
title=str(item.get("title") or ""),
|
||||||
|
url=str(item.get("url") or ""),
|
||||||
|
snippet=str(item.get("summary") or ""),
|
||||||
|
content=str(item.get("text") or ""),
|
||||||
|
score=_float_or_none(item.get("score")),
|
||||||
|
source_provider="exa",
|
||||||
|
metadata={"id": item.get("id")},
|
||||||
|
)
|
||||||
|
for item in data.get("results") or []
|
||||||
|
if isinstance(item, dict) and item.get("url")
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _search_firecrawl(
|
||||||
|
self,
|
||||||
|
config: WebSearchProviderConfig,
|
||||||
|
query: str,
|
||||||
|
max_results: int,
|
||||||
|
) -> list[SearchEvidence]:
|
||||||
|
data = await self._request_json(
|
||||||
|
"POST",
|
||||||
|
_join_url(config.base_url, config.search_path or "/v2/search"),
|
||||||
|
provider_config=config,
|
||||||
|
headers={"Authorization": f"Bearer {config.api_key}"},
|
||||||
|
json={"query": query, "limit": max_results},
|
||||||
|
)
|
||||||
|
raw_results = data.get("data") or data.get("results") or []
|
||||||
|
return [
|
||||||
|
SearchEvidence(
|
||||||
|
title=str(item.get("title") or ""),
|
||||||
|
url=str(item.get("url") or item.get("sourceURL") or ""),
|
||||||
|
snippet=str(item.get("description") or item.get("markdown") or ""),
|
||||||
|
source_provider="firecrawl",
|
||||||
|
metadata={"status": item.get("status")},
|
||||||
|
)
|
||||||
|
for item in raw_results
|
||||||
|
if isinstance(item, dict) and (item.get("url") or item.get("sourceURL"))
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _search_searxng(
|
||||||
|
self,
|
||||||
|
config: WebSearchProviderConfig,
|
||||||
|
query: str,
|
||||||
|
max_results: int,
|
||||||
|
domains: list[str] | None,
|
||||||
|
) -> list[SearchEvidence]:
|
||||||
|
search_query = query
|
||||||
|
if domains:
|
||||||
|
search_query = f"{query} " + " ".join(f"site:{domain}" for domain in domains)
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"q": search_query,
|
||||||
|
"format": "json",
|
||||||
|
"categories": config.categories or "general",
|
||||||
|
}
|
||||||
|
if config.engines:
|
||||||
|
params["engines"] = ",".join(config.engines)
|
||||||
|
headers = {"Authorization": f"Bearer {config.api_key}"} if config.api_key else None
|
||||||
|
data = await self._request_json(
|
||||||
|
"GET",
|
||||||
|
_join_url(config.base_url, config.endpoint_path or "/"),
|
||||||
|
provider_config=config,
|
||||||
|
headers=headers,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
results = data.get("results") or []
|
||||||
|
evidence = [
|
||||||
|
SearchEvidence(
|
||||||
|
title=str(item.get("title") or ""),
|
||||||
|
url=str(item.get("url") or ""),
|
||||||
|
snippet=str(item.get("content") or ""),
|
||||||
|
score=_float_or_none(item.get("score")),
|
||||||
|
source_provider="searxng",
|
||||||
|
metadata={"engine": item.get("engine")},
|
||||||
|
)
|
||||||
|
for item in results
|
||||||
|
if isinstance(item, dict) and item.get("url")
|
||||||
|
]
|
||||||
|
return evidence[:max_results]
|
||||||
|
|
||||||
|
|
||||||
|
def _join_url(base_url: str, path: str) -> str:
|
||||||
|
return f"{(base_url or '').rstrip('/')}/{(path or '').lstrip('/')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _float_or_none(value: Any) -> float | None:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ from sqlalchemy import select
|
|||||||
from app.models.system_setting import SystemSetting
|
from app.models.system_setting import SystemSetting
|
||||||
from app.schemas.ai import SituationalAnalysisRequest
|
from app.schemas.ai import SituationalAnalysisRequest
|
||||||
from app.services.ai_client import AIProviderClient
|
from app.services.ai_client import AIProviderClient
|
||||||
|
from app.services.ai_tools.evidence_store import normalize_search_evidence
|
||||||
|
from app.services.ai_tools.web_search import WebSearchClient, WebSearchError
|
||||||
|
|
||||||
|
|
||||||
CREDENTIAL_GUIDES_CATEGORY = "collector_credential_guides"
|
CREDENTIAL_GUIDES_CATEGORY = "collector_credential_guides"
|
||||||
@@ -153,10 +155,26 @@ async def get_credential_guide(db, provider: str) -> dict[str, Any]:
|
|||||||
"markdown": custom.get("markdown") if custom else default.markdown,
|
"markdown": custom.get("markdown") if custom else default.markdown,
|
||||||
"prompt": default.prompt,
|
"prompt": default.prompt,
|
||||||
"source": "ai" if custom else "default",
|
"source": "ai" if custom else "default",
|
||||||
|
"sources": custom.get("sources", []) if custom else [],
|
||||||
|
"verification_status": (
|
||||||
|
custom.get("verification_status", "verified_with_search_evidence")
|
||||||
|
if custom
|
||||||
|
else "default_unverified"
|
||||||
|
),
|
||||||
|
"verification_error": custom.get("verification_error") if custom else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def save_credential_guide(db, provider: str, title: str, markdown: str) -> dict[str, Any]:
|
async def save_credential_guide(
|
||||||
|
db,
|
||||||
|
provider: str,
|
||||||
|
title: str,
|
||||||
|
markdown: str,
|
||||||
|
*,
|
||||||
|
sources: list[dict[str, Any]] | None = None,
|
||||||
|
verification_status: str = "verified_with_search_evidence",
|
||||||
|
verification_error: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
|
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
|
||||||
if default is None:
|
if default is None:
|
||||||
raise ValueError(f"Unsupported credential guide provider: {provider}")
|
raise ValueError(f"Unsupported credential guide provider: {provider}")
|
||||||
@@ -165,6 +183,9 @@ async def save_credential_guide(db, provider: str, title: str, markdown: str) ->
|
|||||||
store[provider] = {
|
store[provider] = {
|
||||||
"title": title or default.title,
|
"title": title or default.title,
|
||||||
"markdown": markdown,
|
"markdown": markdown,
|
||||||
|
"sources": sources or [],
|
||||||
|
"verification_status": verification_status,
|
||||||
|
"verification_error": verification_error,
|
||||||
}
|
}
|
||||||
if record is None:
|
if record is None:
|
||||||
db.add(SystemSetting(category=CREDENTIAL_GUIDES_CATEGORY, payload=store))
|
db.add(SystemSetting(category=CREDENTIAL_GUIDES_CATEGORY, payload=store))
|
||||||
@@ -192,28 +213,58 @@ async def generate_credential_guide(
|
|||||||
db,
|
db,
|
||||||
provider: str,
|
provider: str,
|
||||||
ai_client: AIProviderClient,
|
ai_client: AIProviderClient,
|
||||||
|
web_search_client: WebSearchClient | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
|
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
|
||||||
if default is None:
|
if default is None:
|
||||||
raise ValueError(f"Unsupported credential guide provider: {provider}")
|
raise ValueError(f"Unsupported credential guide provider: {provider}")
|
||||||
|
|
||||||
|
search_evidence: list[dict[str, Any]] = []
|
||||||
|
search_error: str | None = None
|
||||||
|
if web_search_client is not None:
|
||||||
|
try:
|
||||||
|
evidence = await web_search_client.search(
|
||||||
|
_credential_guide_search_query(default),
|
||||||
|
max_results=5,
|
||||||
|
)
|
||||||
|
search_evidence = normalize_search_evidence(evidence, limit=5)
|
||||||
|
except WebSearchError as exc:
|
||||||
|
search_error = str(exc)
|
||||||
|
except Exception as exc:
|
||||||
|
search_error = f"WebSearch unavailable: {exc}"
|
||||||
|
|
||||||
|
if not search_evidence:
|
||||||
|
guide = await get_credential_guide(db, provider)
|
||||||
|
guide["verification_status"] = "unverified_no_search_evidence"
|
||||||
|
guide["verification_error"] = search_error
|
||||||
|
guide["sources"] = []
|
||||||
|
return guide
|
||||||
|
|
||||||
response = await ai_client.analyze(
|
response = await ai_client.analyze(
|
||||||
SituationalAnalysisRequest(
|
SituationalAnalysisRequest(
|
||||||
title=f"Generate credential guide for {provider}",
|
title=f"Generate credential guide for {provider}",
|
||||||
objective=default.prompt,
|
objective=(
|
||||||
|
default.prompt
|
||||||
|
+ "\n只能根据 context.search_evidence 中的来源生成教程;"
|
||||||
|
+ "如果证据不足,明确说明需要以官方页面为准。"
|
||||||
|
),
|
||||||
context={
|
context={
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"current_default_guide": default.markdown,
|
"current_default_guide": default.markdown,
|
||||||
"product_context": "Planet collector credential settings",
|
"product_context": "Planet collector credential settings",
|
||||||
|
"search_evidence": search_evidence,
|
||||||
},
|
},
|
||||||
observations=[
|
observations=[
|
||||||
"Use concise Chinese markdown.",
|
"Use concise Chinese markdown.",
|
||||||
"Prefer stable concepts over brittle UI labels.",
|
"Prefer stable concepts over brittle UI labels.",
|
||||||
"Include verification and troubleshooting steps.",
|
"Include verification and troubleshooting steps.",
|
||||||
|
"Include a short sources section with the provided URLs.",
|
||||||
],
|
],
|
||||||
constraints=[
|
constraints=[
|
||||||
"Do not ask the user for secrets.",
|
"Do not ask the user for secrets.",
|
||||||
"Do not include fabricated screenshots.",
|
"Do not include fabricated screenshots.",
|
||||||
|
"Do not invent source URLs or product UI labels.",
|
||||||
|
"Use only the provided search_evidence as factual support.",
|
||||||
"Return markdown only.",
|
"Return markdown only.",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -221,4 +272,19 @@ async def generate_credential_guide(
|
|||||||
markdown = response.content.strip()
|
markdown = response.content.strip()
|
||||||
if not markdown:
|
if not markdown:
|
||||||
markdown = default.markdown
|
markdown = default.markdown
|
||||||
return await save_credential_guide(db, provider, default.title, markdown)
|
return await save_credential_guide(
|
||||||
|
db,
|
||||||
|
provider,
|
||||||
|
default.title,
|
||||||
|
markdown,
|
||||||
|
sources=search_evidence,
|
||||||
|
verification_status="verified_with_search_evidence",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_guide_search_query(default: CredentialGuideDefault) -> str:
|
||||||
|
if default.provider == "barentswatch":
|
||||||
|
return "BarentsWatch developer tutorial AIS API OAuth client credentials"
|
||||||
|
if default.provider == "aisstream":
|
||||||
|
return "AISStream API key documentation websocket stream"
|
||||||
|
return f"{default.provider} API credentials documentation"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from typing import Any, Iterable
|
|||||||
from app.core.countries import COUNTRY_ENTRIES, normalize_country
|
from app.core.countries import COUNTRY_ENTRIES, normalize_country
|
||||||
from app.schemas.ai import SituationalAnalysisRequest
|
from app.schemas.ai import SituationalAnalysisRequest
|
||||||
from app.services.ai_client import AIProviderClient
|
from app.services.ai_client import AIProviderClient
|
||||||
|
from app.services.ai_tools.evidence_store import normalize_search_evidence
|
||||||
|
from app.services.ai_tools.web_search import WebSearchClient, WebSearchError
|
||||||
from app.services.location.models import LocationCandidate, LocationQuery
|
from app.services.location.models import LocationCandidate, LocationQuery
|
||||||
from app.services.location.resolvers.nominatim import build_default_nominatim_geocoder
|
from app.services.location.resolvers.nominatim import build_default_nominatim_geocoder
|
||||||
from app.services.location.text import (
|
from app.services.location.text import (
|
||||||
@@ -69,6 +71,13 @@ class LocationLLMFallbackResult:
|
|||||||
failure_reason: str | None = None
|
failure_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LocationSearchEvidenceResult:
|
||||||
|
evidence: list[dict[str, Any]]
|
||||||
|
attempted_queries: list[str]
|
||||||
|
failure_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LocationEvidenceScore:
|
class LocationEvidenceScore:
|
||||||
score: float
|
score: float
|
||||||
@@ -751,6 +760,10 @@ def _candidate_from_payload(
|
|||||||
"name_location_hint": evidence_score.name_location_hint,
|
"name_location_hint": evidence_score.name_location_hint,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
raw_payload={
|
||||||
|
"llm_payload": payload,
|
||||||
|
"search_evidence": payload.get("search_evidence") or [],
|
||||||
|
},
|
||||||
), None
|
), None
|
||||||
|
|
||||||
|
|
||||||
@@ -802,6 +815,61 @@ def _observations(query: LocationQuery, attempted_queries: Iterable[str]) -> lis
|
|||||||
return observations
|
return observations
|
||||||
|
|
||||||
|
|
||||||
|
def _location_search_query(query: LocationQuery, entity_type: str) -> str:
|
||||||
|
extra = query.extra or {}
|
||||||
|
parts = [
|
||||||
|
coerce_str(query.name),
|
||||||
|
coerce_str(extra.get("site")),
|
||||||
|
coerce_str(extra.get("operator")),
|
||||||
|
coerce_str(extra.get("organization")),
|
||||||
|
coerce_str(query.city),
|
||||||
|
coerce_str(query.country),
|
||||||
|
"physical location",
|
||||||
|
]
|
||||||
|
if entity_type == "bgp_collector":
|
||||||
|
parts.append("route collector city")
|
||||||
|
elif entity_type == "compute_center":
|
||||||
|
parts.append("datacenter supercomputer facility city")
|
||||||
|
return " ".join(part for part in parts if part)
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_location_search_evidence(
|
||||||
|
*,
|
||||||
|
web_search_client: WebSearchClient,
|
||||||
|
query: LocationQuery,
|
||||||
|
entity_type: str,
|
||||||
|
max_results: int = 5,
|
||||||
|
) -> LocationSearchEvidenceResult:
|
||||||
|
search_query = _location_search_query(query, entity_type)
|
||||||
|
attempt = f"web_search:{entity_type}:{search_query}"
|
||||||
|
try:
|
||||||
|
evidence = await web_search_client.search(search_query, max_results=max_results)
|
||||||
|
except WebSearchError as exc:
|
||||||
|
return LocationSearchEvidenceResult(
|
||||||
|
evidence=[],
|
||||||
|
attempted_queries=[attempt],
|
||||||
|
failure_reason=f"WebSearch location evidence failed: {exc}",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return LocationSearchEvidenceResult(
|
||||||
|
evidence=[],
|
||||||
|
attempted_queries=[attempt],
|
||||||
|
failure_reason=f"WebSearch location evidence unavailable: {exc}",
|
||||||
|
)
|
||||||
|
normalized = normalize_search_evidence(evidence, limit=max_results)
|
||||||
|
if not normalized:
|
||||||
|
return LocationSearchEvidenceResult(
|
||||||
|
evidence=[],
|
||||||
|
attempted_queries=[attempt],
|
||||||
|
failure_reason="WebSearch returned no usable location evidence.",
|
||||||
|
)
|
||||||
|
return LocationSearchEvidenceResult(
|
||||||
|
evidence=normalized,
|
||||||
|
attempted_queries=[attempt],
|
||||||
|
failure_reason=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _repair_location_payload_from_text(
|
async def _repair_location_payload_from_text(
|
||||||
*,
|
*,
|
||||||
provider_client: AIProviderClient,
|
provider_client: AIProviderClient,
|
||||||
@@ -862,6 +930,7 @@ async def collect_llm_location_fallback_candidate(
|
|||||||
query: LocationQuery,
|
query: LocationQuery,
|
||||||
entity_type: str,
|
entity_type: str,
|
||||||
attempted_queries: Iterable[str] = (),
|
attempted_queries: Iterable[str] = (),
|
||||||
|
search_evidence: list[dict[str, Any]] | None = None,
|
||||||
min_confidence: float = DEFAULT_MIN_CONFIDENCE,
|
min_confidence: float = DEFAULT_MIN_CONFIDENCE,
|
||||||
) -> LocationLLMFallbackResult:
|
) -> LocationLLMFallbackResult:
|
||||||
"""Ask the configured LLM for one fact-checked location candidate.
|
"""Ask the configured LLM for one fact-checked location candidate.
|
||||||
@@ -871,6 +940,12 @@ async def collect_llm_location_fallback_candidate(
|
|||||||
use this in user-triggered collection flows.
|
use this in user-triggered collection flows.
|
||||||
"""
|
"""
|
||||||
attempt = f"llm_factcheck:{entity_type}:{coerce_str(query.name) or 'unknown'}"
|
attempt = f"llm_factcheck:{entity_type}:{coerce_str(query.name) or 'unknown'}"
|
||||||
|
if search_evidence is not None and not search_evidence:
|
||||||
|
return LocationLLMFallbackResult(
|
||||||
|
candidates=[],
|
||||||
|
attempted_queries=[attempt],
|
||||||
|
failure_reason="LLM location factcheck skipped: no WebSearch evidence.",
|
||||||
|
)
|
||||||
request = SituationalAnalysisRequest(
|
request = SituationalAnalysisRequest(
|
||||||
title=f"Location factcheck fallback for {entity_type}",
|
title=f"Location factcheck fallback for {entity_type}",
|
||||||
objective=(
|
objective=(
|
||||||
@@ -881,6 +956,7 @@ async def collect_llm_location_fallback_candidate(
|
|||||||
context={
|
context={
|
||||||
"entity_type": entity_type,
|
"entity_type": entity_type,
|
||||||
"location_query": _query_context(query),
|
"location_query": _query_context(query),
|
||||||
|
"search_evidence": search_evidence or [],
|
||||||
"required_json_schema": {
|
"required_json_schema": {
|
||||||
"latitude": "number",
|
"latitude": "number",
|
||||||
"longitude": "number",
|
"longitude": "number",
|
||||||
@@ -905,6 +981,7 @@ async def collect_llm_location_fallback_candidate(
|
|||||||
"Calibrate model confidence using this rubric: 0.85-1.0 for exact facility coordinates backed by an authoritative source; 0.70-0.84 for a confirmed facility/campus with strong public evidence; 0.55-0.69 for a confirmed city-level location backed by credible sources but without exact facility coordinates; 0.35-0.54 for weak or ambiguous city evidence; below 0.35 when the location is mostly a guess.",
|
"Calibrate model confidence using this rubric: 0.85-1.0 for exact facility coordinates backed by an authoritative source; 0.70-0.84 for a confirmed facility/campus with strong public evidence; 0.55-0.69 for a confirmed city-level location backed by credible sources but without exact facility coordinates; 0.35-0.54 for weak or ambiguous city evidence; below 0.35 when the location is mostly a guess.",
|
||||||
"Return evidence as objects when possible, including source, url, source_type, and entity_match.",
|
"Return evidence as objects when possible, including source, url, source_type, and entity_match.",
|
||||||
"Include source names or URLs in evidence when known. The backend will recompute the final confidence from model confidence plus evidence quality.",
|
"Include source names or URLs in evidence when known. The backend will recompute the final confidence from model confidence plus evidence quality.",
|
||||||
|
"If search_evidence is provided, use only that evidence as factual support.",
|
||||||
"Prefer the facility/site if known; otherwise use the best supported city.",
|
"Prefer the facility/site if known; otherwise use the best supported city.",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -939,6 +1016,23 @@ async def collect_llm_location_fallback_candidate(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
payload = _normalize_llm_payload(payload)
|
payload = _normalize_llm_payload(payload)
|
||||||
|
if search_evidence:
|
||||||
|
payload["search_evidence"] = search_evidence
|
||||||
|
existing_evidence = _evidence_items(payload.get("evidence"))
|
||||||
|
payload["evidence"] = [
|
||||||
|
*existing_evidence,
|
||||||
|
*[
|
||||||
|
{
|
||||||
|
"source": item.get("title") or item.get("url"),
|
||||||
|
"url": item.get("url"),
|
||||||
|
"text": item.get("snippet") or item.get("content"),
|
||||||
|
"source_type": "web_search",
|
||||||
|
"entity_match": True,
|
||||||
|
}
|
||||||
|
for item in search_evidence
|
||||||
|
if isinstance(item, dict)
|
||||||
|
],
|
||||||
|
]
|
||||||
latitude, longitude = _extract_llm_coordinates(payload)
|
latitude, longitude = _extract_llm_coordinates(payload)
|
||||||
city_geocode_failure = None
|
city_geocode_failure = None
|
||||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class LocationCandidate:
|
|||||||
matched_location_name: str | None = None
|
matched_location_name: str | None = None
|
||||||
location_verified_at: str | None = None
|
location_verified_at: str | None = None
|
||||||
suggested_registry_entry: dict[str, Any] | None = None
|
suggested_registry_entry: dict[str, Any] | None = None
|
||||||
|
raw_payload: dict[str, Any] | None = None
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -70,6 +71,7 @@ class LocationCandidate:
|
|||||||
"matched_location_name": self.matched_location_name,
|
"matched_location_name": self.matched_location_name,
|
||||||
"location_verified_at": self.location_verified_at,
|
"location_verified_at": self.location_verified_at,
|
||||||
"suggested_registry_entry": self.suggested_registry_entry,
|
"suggested_registry_entry": self.suggested_registry_entry,
|
||||||
|
"raw_payload": self.raw_payload,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,20 @@ async def test_collect_bgp_collector_location_uses_llm_when_candidates_empty(mon
|
|||||||
lambda **_kwargs: ([], ["Lyon, France"]),
|
lambda **_kwargs: ([], ["Lyon, France"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.services.location.llm_fallback import LocationSearchEvidenceResult
|
||||||
|
|
||||||
|
async def _search_evidence(**_kwargs):
|
||||||
|
return LocationSearchEvidenceResult(
|
||||||
|
evidence=[
|
||||||
|
{
|
||||||
|
"title": "RRC source",
|
||||||
|
"url": "https://example.test/rrc",
|
||||||
|
"snippet": "rrc-mystery is in Lyon.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
attempted_queries=["web_search:bgp_collector:rrc-mystery Lyon France physical location route collector city"],
|
||||||
|
)
|
||||||
|
|
||||||
async def _fallback(**_kwargs):
|
async def _fallback(**_kwargs):
|
||||||
return LocationLLMFallbackResult(
|
return LocationLLMFallbackResult(
|
||||||
candidates=[llm_candidate],
|
candidates=[llm_candidate],
|
||||||
@@ -144,6 +158,8 @@ async def test_collect_bgp_collector_location_uses_llm_when_candidates_empty(mon
|
|||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(bgp_api, "get_ai_provider_client", AsyncMock(return_value=object()))
|
monkeypatch.setattr(bgp_api, "get_ai_provider_client", AsyncMock(return_value=object()))
|
||||||
|
monkeypatch.setattr(bgp_api, "get_web_search_client", AsyncMock(return_value=object()))
|
||||||
|
monkeypatch.setattr(bgp_api, "collect_location_search_evidence", _search_evidence)
|
||||||
monkeypatch.setattr(bgp_api, "collect_llm_location_fallback_candidate", _fallback)
|
monkeypatch.setattr(bgp_api, "collect_llm_location_fallback_candidate", _fallback)
|
||||||
|
|
||||||
response = await bgp_api.collect_bgp_collector_location(
|
response = await bgp_api.collect_bgp_collector_location(
|
||||||
@@ -158,6 +174,7 @@ async def test_collect_bgp_collector_location_uses_llm_when_candidates_empty(mon
|
|||||||
assert response["best_candidate"]["needs_confirmation"] is True
|
assert response["best_candidate"]["needs_confirmation"] is True
|
||||||
assert response["attempted_queries"] == [
|
assert response["attempted_queries"] == [
|
||||||
"Lyon, France",
|
"Lyon, France",
|
||||||
|
"web_search:bgp_collector:rrc-mystery Lyon France physical location route collector city",
|
||||||
"llm_factcheck:bgp_collector:rrc-mystery",
|
"llm_factcheck:bgp_collector:rrc-mystery",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -569,7 +569,19 @@ async def test_collect_compute_center_location_uses_llm_when_candidates_empty(mo
|
|||||||
lambda **_kwargs: ([], ["Mystery Cluster, France"]),
|
lambda **_kwargs: ([], ["Mystery Cluster, France"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.services.location.llm_fallback import LocationLLMFallbackResult
|
from app.services.location.llm_fallback import LocationLLMFallbackResult, LocationSearchEvidenceResult
|
||||||
|
|
||||||
|
async def _search_evidence(**_kwargs):
|
||||||
|
return LocationSearchEvidenceResult(
|
||||||
|
evidence=[
|
||||||
|
{
|
||||||
|
"title": "Mystery Cluster source",
|
||||||
|
"url": "https://example.test/mystery",
|
||||||
|
"snippet": "Mystery Cluster is in Lyon.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
attempted_queries=["web_search:compute_center:Mystery Cluster France physical location"],
|
||||||
|
)
|
||||||
|
|
||||||
async def _fallback(**_kwargs):
|
async def _fallback(**_kwargs):
|
||||||
return LocationLLMFallbackResult(
|
return LocationLLMFallbackResult(
|
||||||
@@ -578,6 +590,8 @@ async def test_collect_compute_center_location_uses_llm_when_candidates_empty(mo
|
|||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(visualization_api, "get_ai_provider_client", AsyncMock(return_value=object()))
|
monkeypatch.setattr(visualization_api, "get_ai_provider_client", AsyncMock(return_value=object()))
|
||||||
|
monkeypatch.setattr(visualization_api, "get_web_search_client", AsyncMock(return_value=object()))
|
||||||
|
monkeypatch.setattr(visualization_api, "collect_location_search_evidence", _search_evidence)
|
||||||
monkeypatch.setattr(visualization_api, "collect_llm_location_fallback_candidate", _fallback)
|
monkeypatch.setattr(visualization_api, "collect_llm_location_fallback_candidate", _fallback)
|
||||||
|
|
||||||
response = await visualization_api.collect_compute_center_location(
|
response = await visualization_api.collect_compute_center_location(
|
||||||
@@ -595,6 +609,7 @@ async def test_collect_compute_center_location_uses_llm_when_candidates_empty(mo
|
|||||||
assert response["best_candidate"]["needs_confirmation"] is True
|
assert response["best_candidate"]["needs_confirmation"] is True
|
||||||
assert response["attempted_queries"] == [
|
assert response["attempted_queries"] == [
|
||||||
"Mystery Cluster, France",
|
"Mystery Cluster, France",
|
||||||
|
"web_search:compute_center:Mystery Cluster France physical location",
|
||||||
"llm_factcheck:compute_center:Mystery Cluster",
|
"llm_factcheck:compute_center:Mystery Cluster",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
261
backend/tests/test_web_search_tools.py
Normal file
261
backend/tests/test_web_search_tools.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.api.v1 import settings as settings_api
|
||||||
|
from app.api.v1.settings import (
|
||||||
|
WebSearchIntegrationUpdate,
|
||||||
|
_build_web_search_payload,
|
||||||
|
_mask_secret,
|
||||||
|
_normalize_web_search_payload,
|
||||||
|
_resolve_web_search_api_key,
|
||||||
|
)
|
||||||
|
from app.services.ai_tools.schemas import WebSearchConfig, WebSearchProviderConfig
|
||||||
|
from app.services.ai_tools.web_search import WebSearchClient
|
||||||
|
from app.services.credential_guides import generate_credential_guide
|
||||||
|
from app.services.location.llm_fallback import (
|
||||||
|
collect_llm_location_fallback_candidate,
|
||||||
|
collect_location_search_evidence,
|
||||||
|
)
|
||||||
|
from app.services.location.models import LocationQuery
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolated_web_search_env_files(monkeypatch, tmp_path):
|
||||||
|
env_file = tmp_path / ".env"
|
||||||
|
monkeypatch.setattr(settings_api, "WEB_SEARCH_ENV_FILES", (env_file,))
|
||||||
|
return env_file
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_web_search_payload_adds_default_provider():
|
||||||
|
payload = _normalize_web_search_payload({})
|
||||||
|
|
||||||
|
assert payload["default_provider"] == "tavily"
|
||||||
|
assert payload["providers"]["tavily"]["base_url"] == "https://api.tavily.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_search_key_prefers_provider_env(isolated_web_search_env_files):
|
||||||
|
isolated_web_search_env_files.write_text(
|
||||||
|
"TAVILY_API_KEY=tavily-env-key\nWEB_SEARCH_API_KEY=generic-search-key\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
value, source = _resolve_web_search_api_key("tavily", {"api_key": ""})
|
||||||
|
|
||||||
|
assert value == "tavily-env-key"
|
||||||
|
assert source == "env_file"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_web_search_payload_keeps_saved_key_when_preview_submitted():
|
||||||
|
current = {
|
||||||
|
"web_search": {
|
||||||
|
"default_provider": "tavily",
|
||||||
|
"providers": {
|
||||||
|
"tavily": {
|
||||||
|
"provider": "tavily",
|
||||||
|
"api_key": "tvly-old-secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update = WebSearchIntegrationUpdate(
|
||||||
|
enabled=True,
|
||||||
|
provider="tavily",
|
||||||
|
base_url="https://api.tavily.com",
|
||||||
|
api_key=_mask_secret("tvly-old-secret")["preview"],
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = _build_web_search_payload(current, update)
|
||||||
|
|
||||||
|
assert payload["enabled"] is True
|
||||||
|
assert payload["providers"]["tavily"]["api_key"] == "tvly-old-secret"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tavily_adapter_normalizes_results(monkeypatch):
|
||||||
|
config = WebSearchConfig(
|
||||||
|
enabled=True,
|
||||||
|
default_provider="tavily",
|
||||||
|
provider="tavily",
|
||||||
|
providers={
|
||||||
|
"tavily": WebSearchProviderConfig(
|
||||||
|
provider="tavily",
|
||||||
|
base_url="https://api.tavily.com",
|
||||||
|
api_key="key",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
client = WebSearchClient(config)
|
||||||
|
|
||||||
|
async def fake_request_json(*args, **kwargs):
|
||||||
|
return {
|
||||||
|
"query": "Alem.Cloud",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"title": "Alem.Cloud official",
|
||||||
|
"url": "https://example.test/alem",
|
||||||
|
"content": "Alem.Cloud is in Astana.",
|
||||||
|
"score": 0.9,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(client, "_request_json", fake_request_json)
|
||||||
|
|
||||||
|
results = await client.search("Alem.Cloud")
|
||||||
|
|
||||||
|
assert results[0].source_provider == "tavily"
|
||||||
|
assert results[0].url == "https://example.test/alem"
|
||||||
|
assert "Astana" in results[0].snippet
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_searxng_adapter_allows_empty_api_key(monkeypatch):
|
||||||
|
config = WebSearchConfig(
|
||||||
|
enabled=True,
|
||||||
|
default_provider="searxng",
|
||||||
|
provider="searxng",
|
||||||
|
providers={
|
||||||
|
"searxng": WebSearchProviderConfig(
|
||||||
|
provider="searxng",
|
||||||
|
base_url="http://localhost:8080",
|
||||||
|
api_key="",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
client = WebSearchClient(config)
|
||||||
|
|
||||||
|
async def fake_request_json(*args, **kwargs):
|
||||||
|
return {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"title": "TAIPEI-1",
|
||||||
|
"url": "https://example.test/taipei",
|
||||||
|
"content": "TAIPEI-1 is in Taipei.",
|
||||||
|
"score": 2,
|
||||||
|
"engine": "duckduckgo",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(client, "_request_json", fake_request_json)
|
||||||
|
|
||||||
|
results = await client.search("TAIPEI-1")
|
||||||
|
|
||||||
|
assert results[0].source_provider == "searxng"
|
||||||
|
assert results[0].metadata["engine"] == "duckduckgo"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_location_search_evidence_returns_failure_on_empty_results(monkeypatch):
|
||||||
|
class EmptySearchClient:
|
||||||
|
async def search(self, *args, **kwargs):
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = await collect_location_search_evidence(
|
||||||
|
web_search_client=EmptySearchClient(),
|
||||||
|
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||||
|
entity_type="compute_center",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.evidence == []
|
||||||
|
assert "no usable" in result.failure_reason
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_llm_location_fallback_skips_when_search_evidence_empty():
|
||||||
|
class ExplodingAIClient:
|
||||||
|
async def analyze(self, *_args, **_kwargs):
|
||||||
|
raise AssertionError("LLM should not be called without evidence")
|
||||||
|
|
||||||
|
result = await collect_llm_location_fallback_candidate(
|
||||||
|
provider_client=ExplodingAIClient(),
|
||||||
|
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||||
|
entity_type="compute_center",
|
||||||
|
search_evidence=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.candidates == []
|
||||||
|
assert "no WebSearch evidence" in result.failure_reason
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_credential_guide_keeps_default_without_search_evidence():
|
||||||
|
class EmptySearchClient:
|
||||||
|
async def search(self, *args, **kwargs):
|
||||||
|
return []
|
||||||
|
|
||||||
|
class ExplodingAIClient:
|
||||||
|
async def analyze(self, *_args, **_kwargs):
|
||||||
|
raise AssertionError("AI should not be called without search evidence")
|
||||||
|
|
||||||
|
async def fake_get_store(_db):
|
||||||
|
return None, {}
|
||||||
|
|
||||||
|
import app.services.credential_guides as credential_guides
|
||||||
|
|
||||||
|
original = credential_guides._get_guide_store
|
||||||
|
credential_guides._get_guide_store = fake_get_store
|
||||||
|
try:
|
||||||
|
guide = await generate_credential_guide(
|
||||||
|
object(),
|
||||||
|
"barentswatch",
|
||||||
|
ExplodingAIClient(),
|
||||||
|
EmptySearchClient(),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
credential_guides._get_guide_store = original
|
||||||
|
|
||||||
|
assert guide["source"] == "default"
|
||||||
|
assert guide["verification_status"] == "unverified_no_search_evidence"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_credential_guide_uses_search_evidence(monkeypatch):
|
||||||
|
class SearchClient:
|
||||||
|
async def search(self, *args, **kwargs):
|
||||||
|
from app.services.ai_tools.schemas import SearchEvidence
|
||||||
|
|
||||||
|
return [
|
||||||
|
SearchEvidence(
|
||||||
|
title="Official docs",
|
||||||
|
url="https://docs.example.test",
|
||||||
|
snippet="Create an AIS client.",
|
||||||
|
source_provider="tavily",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
class AIClient:
|
||||||
|
async def analyze(self, payload):
|
||||||
|
assert payload.context["search_evidence"]
|
||||||
|
return SimpleNamespace(content="## Generated\n\nSources included.")
|
||||||
|
|
||||||
|
saved = {}
|
||||||
|
|
||||||
|
async def fake_get_store(_db):
|
||||||
|
return None, saved
|
||||||
|
|
||||||
|
async def fake_save(db, provider, title, markdown, **metadata):
|
||||||
|
return {
|
||||||
|
"provider": provider,
|
||||||
|
"title": title,
|
||||||
|
"markdown": markdown,
|
||||||
|
"source": "ai",
|
||||||
|
**metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
import app.services.credential_guides as credential_guides
|
||||||
|
|
||||||
|
monkeypatch.setattr(credential_guides, "_get_guide_store", fake_get_store)
|
||||||
|
monkeypatch.setattr(credential_guides, "save_credential_guide", fake_save)
|
||||||
|
|
||||||
|
guide = await generate_credential_guide(
|
||||||
|
object(),
|
||||||
|
"barentswatch",
|
||||||
|
AIClient(),
|
||||||
|
SearchClient(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert guide["source"] == "ai"
|
||||||
|
assert guide["verification_status"] == "verified_with_search_evidence"
|
||||||
|
assert guide["sources"][0]["url"] == "https://docs.example.test"
|
||||||
@@ -8,6 +8,21 @@ This project follows the repository versioning rule:
|
|||||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||||
- `bugfix` -> `+0.0.1`
|
- `bugfix` -> `+0.0.1`
|
||||||
|
|
||||||
|
## [0.51.0] — 2026-05-11
|
||||||
|
|
||||||
|
Released: 2026-05-11
|
||||||
|
|
||||||
|
### ✨ Highlights
|
||||||
|
- 新增 AI Settings 控制台页面与 `backend/app/services/ai_tools/` 工具层,串通 Web Search Provider 与轻量 Agent orchestrator。
|
||||||
|
- 重写 Earth 算力中心候选「预览 / 保存」交互:单一委托 click + 内存 candidate Map,新增空心呼吸圈预览,保存后即时生成正式图标,后台刷新失败不再误报为保存失败。
|
||||||
|
- 重写动作捕捉 zoom 识别:mirror-safe 的 trend + pose hold 双通道,张开/合拢手势直接对应 zoom_in/out 并支持持续触发;单臂 rotate 仅在另一只手明确静止时才允许。
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- 同步中英文 `earth-frontend-context.md`、`frontend-admin-frontend-context.md`、`faq.md`、`manual.md`、`quickstart.md`。
|
||||||
|
- Earth 模块多处优化:bgp-cruise-adapter、interactable、satellites、presentation-controller、controls 调整与回归测试补全。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.50.0] — 2026-05-10
|
## [0.50.0] — 2026-05-10
|
||||||
|
|
||||||
Released: 2026-05-10
|
Released: 2026-05-10
|
||||||
|
|||||||
@@ -179,6 +179,217 @@ Secret resolution should follow the existing settings pattern:
|
|||||||
2. provider-specific environment variable, for example `TAVILY_API_KEY`
|
2. provider-specific environment variable, for example `TAVILY_API_KEY`
|
||||||
3. generic fallback `WEB_SEARCH_API_KEY`
|
3. generic fallback `WEB_SEARCH_API_KEY`
|
||||||
|
|
||||||
|
### Common WebSearch Providers
|
||||||
|
|
||||||
|
The first implementation should model WebSearch as a provider-specific adapter
|
||||||
|
behind one internal interface:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SearchEvidenceProvider.search(query, max_results, domains, freshness_days)
|
||||||
|
-> list[SearchEvidence]
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended provider ids and environment variables:
|
||||||
|
|
||||||
|
| Provider | Provider id | Env key | Default base URL | Primary use |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Tavily | `tavily` | `TAVILY_API_KEY` | `https://api.tavily.com` | Default hosted search for agent/RAG style results |
|
||||||
|
| Brave Search API | `brave` | `BRAVE_SEARCH_API_KEY` | `https://api.search.brave.com` | Independent web index and low-level SERP results |
|
||||||
|
| SerpAPI | `serpapi` | `SERPAPI_API_KEY` | `https://serpapi.com` | Search-engine-backed SERP data with engine options |
|
||||||
|
| Exa | `exa` | `EXA_API_KEY` | `https://api.exa.ai` | Neural/semantic web search and result contents |
|
||||||
|
| Firecrawl Search / Scrape | `firecrawl` | `FIRECRAWL_API_KEY` | `https://api.firecrawl.dev` | Search plus page scrape/markdown extraction |
|
||||||
|
| SearXNG | `searxng` | optional `SEARXNG_API_KEY` | self-hosted instance URL | Self-hosted metasearch when external search APIs are undesirable |
|
||||||
|
|
||||||
|
The normalized configuration should support per-provider defaults while keeping
|
||||||
|
one active provider:
|
||||||
|
|
||||||
|
```text
|
||||||
|
external_integrations.web_search
|
||||||
|
enabled: true
|
||||||
|
default_provider: tavily
|
||||||
|
providers:
|
||||||
|
tavily:
|
||||||
|
base_url: https://api.tavily.com
|
||||||
|
api_key: <secret>
|
||||||
|
max_results: 5
|
||||||
|
search_depth: basic
|
||||||
|
include_answer: false
|
||||||
|
include_raw_content: false
|
||||||
|
brave:
|
||||||
|
base_url: https://api.search.brave.com
|
||||||
|
api_key: <secret>
|
||||||
|
endpoint_path: /res/v1/web/search
|
||||||
|
max_results: 5
|
||||||
|
serpapi:
|
||||||
|
base_url: https://serpapi.com
|
||||||
|
api_key: <secret>
|
||||||
|
endpoint_path: /search.json
|
||||||
|
engine: google
|
||||||
|
max_results: 5
|
||||||
|
exa:
|
||||||
|
base_url: https://api.exa.ai
|
||||||
|
api_key: <secret>
|
||||||
|
endpoint_path: /search
|
||||||
|
max_results: 5
|
||||||
|
include_text: false
|
||||||
|
firecrawl:
|
||||||
|
base_url: https://api.firecrawl.dev
|
||||||
|
api_key: <secret>
|
||||||
|
search_path: /v2/search
|
||||||
|
scrape_path: /v2/scrape
|
||||||
|
max_results: 5
|
||||||
|
scrape_formats: [markdown]
|
||||||
|
searxng:
|
||||||
|
base_url: http://localhost:8080
|
||||||
|
api_key: <optional secret>
|
||||||
|
endpoint_path: /
|
||||||
|
max_results: 5
|
||||||
|
categories: general
|
||||||
|
engines: []
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapter notes:
|
||||||
|
|
||||||
|
- Tavily should call `/search` and normalize title, URL, snippet/content, score,
|
||||||
|
and optional raw content.
|
||||||
|
- Brave should call `/res/v1/web/search` and map web results into the same
|
||||||
|
`SearchEvidence` shape.
|
||||||
|
- SerpAPI should call `/search.json`, pass `engine`, and normalize organic
|
||||||
|
results. Search-engine-specific fields should remain in provider metadata.
|
||||||
|
- Exa should call `/search`; optional result text should be treated as fetched
|
||||||
|
content only when enabled.
|
||||||
|
- Firecrawl can be used both as `web_search` and `web_fetch`: `/v2/search`
|
||||||
|
returns result URLs/descriptions and may include scrape options, while
|
||||||
|
`/v2/scrape` can produce markdown for a selected URL.
|
||||||
|
- SearXNG should query the configured instance with `q` and `format=json`.
|
||||||
|
Public instances should not be assumed reliable for production; a controlled
|
||||||
|
self-hosted instance is preferred.
|
||||||
|
|
||||||
|
The settings UI should expose only provider, base URL, key, max results, and a
|
||||||
|
test button in the first version. Provider-specific advanced fields can stay
|
||||||
|
collapsed or backend-only until a real workflow needs them.
|
||||||
|
|
||||||
|
### Frontend Configuration Window
|
||||||
|
|
||||||
|
Add a WebSearch configuration panel to the existing settings page, next to the
|
||||||
|
LLM provider configuration. It should behave like the current AI provider secret
|
||||||
|
controls: clear configured state, masked preview, explicit show/hide, test
|
||||||
|
connection, and save feedback.
|
||||||
|
|
||||||
|
First-version visible fields:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WebSearch Provider
|
||||||
|
API Base URL
|
||||||
|
API Key
|
||||||
|
Max Results
|
||||||
|
Timeout Seconds
|
||||||
|
Enable WebSearch
|
||||||
|
Test Connection
|
||||||
|
Save
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider dropdown options:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tavily
|
||||||
|
Brave Search API
|
||||||
|
SerpAPI
|
||||||
|
Exa
|
||||||
|
Firecrawl Search / Scrape
|
||||||
|
SearXNG
|
||||||
|
```
|
||||||
|
|
||||||
|
Field behavior:
|
||||||
|
|
||||||
|
- Switching provider loads that provider's saved config and masked key preview.
|
||||||
|
- Empty key input means keep the existing saved or environment key.
|
||||||
|
- Typing a new key replaces only the selected provider's key.
|
||||||
|
- Show key reveals the full current input value when the backend reveal endpoint
|
||||||
|
allows it; hide key returns to the prefix-preserving masked preview.
|
||||||
|
- The configured badge should only show `已配置` or `未配置`, not repeat the
|
||||||
|
masked key text.
|
||||||
|
- `Test Connection` sends the current unsaved draft to the backend and should
|
||||||
|
not require a separate save first.
|
||||||
|
- A successful test may save the draft as the new WebSearch default only if the
|
||||||
|
API endpoint is explicitly designed to mirror the AI provider test behavior.
|
||||||
|
Otherwise, test should be read-only and the Save button should persist.
|
||||||
|
- Save success and test success must show visible feedback. Failures should show
|
||||||
|
provider-specific but secret-safe error messages.
|
||||||
|
|
||||||
|
Provider-specific UI hints:
|
||||||
|
|
||||||
|
| Provider | UI hint |
|
||||||
|
| --- | --- |
|
||||||
|
| Tavily | Good default for agent/RAG style search. |
|
||||||
|
| Brave Search API | Uses Brave's independent search index. |
|
||||||
|
| SerpAPI | Supports search-engine-specific parameters such as `engine`. |
|
||||||
|
| Exa | Good for semantic search and optional result text. |
|
||||||
|
| Firecrawl | Can search and scrape pages into markdown. |
|
||||||
|
| SearXNG | Requires a reachable self-hosted or trusted instance URL. |
|
||||||
|
|
||||||
|
Advanced fields can live in a collapsed section:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Endpoint Path
|
||||||
|
Search Depth
|
||||||
|
Engine
|
||||||
|
Categories
|
||||||
|
Engines
|
||||||
|
Include Raw Content
|
||||||
|
Scrape Formats
|
||||||
|
Domain Allowlist
|
||||||
|
```
|
||||||
|
|
||||||
|
The first version should keep the UI conservative. It should not expose every
|
||||||
|
provider knob until backend workflows use those knobs.
|
||||||
|
|
||||||
|
### Web Fetch and Page Extraction
|
||||||
|
|
||||||
|
`web_fetch` is separate from `web_search`. Search finds candidate URLs; fetch
|
||||||
|
turns selected pages into clean, citable evidence.
|
||||||
|
|
||||||
|
Recommended extraction chain:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. plain httpx fetch
|
||||||
|
2. trafilatura extraction for static HTML
|
||||||
|
3. readability extraction as secondary cleanup
|
||||||
|
4. Playwright fetch only for allowlisted JS-heavy pages
|
||||||
|
5. Firecrawl scrape as hosted fallback when configured
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation guidance:
|
||||||
|
|
||||||
|
- Use `trafilatura` as the first local extractor because it is Python-native and
|
||||||
|
matches the backend stack.
|
||||||
|
- Prefer a Python readability implementation for local cleanup. Do not introduce
|
||||||
|
a Node-only readability dependency for backend fetch.
|
||||||
|
- Use Playwright sparingly for JavaScript-rendered pages. It should have domain
|
||||||
|
allowlists, low concurrency, strict timeouts, response size limits, and no
|
||||||
|
automatic form submission or login behavior.
|
||||||
|
- Store `content_hash`, `retrieved_at`, final URL, title, extracted text
|
||||||
|
preview, and extractor name in `ai_evidence`.
|
||||||
|
- Keep short quotes for UI review, but do not store huge page bodies directly in
|
||||||
|
every task record. Large extracted content should be truncated or stored once
|
||||||
|
by hash.
|
||||||
|
|
||||||
|
The local/self-hosted stack should look like this:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SearXNG
|
||||||
|
-> SearchEvidence URLs
|
||||||
|
-> httpx fetch
|
||||||
|
-> trafilatura / readability
|
||||||
|
-> Playwright only when static extraction fails and the domain is allowed
|
||||||
|
-> normalized evidence
|
||||||
|
-> LLM structured output through AIProviderClient
|
||||||
|
```
|
||||||
|
|
||||||
|
This route gives Planet a lower-cost and more controllable search path, while
|
||||||
|
hosted providers remain available when search quality or maintenance effort
|
||||||
|
matters more than self-hosting.
|
||||||
|
|
||||||
|
|
||||||
## Phase 3: Limited Agent Loop
|
## Phase 3: Limited Agent Loop
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,18 @@ Gesture recognition may run locally in the browser or inside the local Agent, bu
|
|||||||
|
|
||||||
[motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) owns the debug panel. It listens for `earth:motion-debug-frame` and draws normalized skeleton joints and bones on a canvas. The Browser Camera provider also emits `earth:motion-debug-video-source` with the local `<video>` element so the panel can show a local preview behind the skeleton; `shared.motionDebugSkeletonOnly` switches the panel back to skeleton-only rendering. `Stop Matching Gestures` dispatches `earth:motion-recognition-pause`, which suppresses gesture execution while video and skeleton drawing continue. Unmatched skeletons are red; matched gestures turn green and display the gesture name. Settings are persisted under `shared.motionDebugEnabled`, `shared.motionProvider`, and `shared.motionDebugSkeletonOnly` in `planet.earth.settings.v2`, and both the switch and provider selector reserve `data-gatekeeper-permission="earth.motion_debug"`.
|
[motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) owns the debug panel. It listens for `earth:motion-debug-frame` and draws normalized skeleton joints and bones on a canvas. The Browser Camera provider also emits `earth:motion-debug-video-source` with the local `<video>` element so the panel can show a local preview behind the skeleton; `shared.motionDebugSkeletonOnly` switches the panel back to skeleton-only rendering. `Stop Matching Gestures` dispatches `earth:motion-recognition-pause`, which suppresses gesture execution while video and skeleton drawing continue. Unmatched skeletons are red; matched gestures turn green and display the gesture name. Settings are persisted under `shared.motionDebugEnabled`, `shared.motionProvider`, and `shared.motionDebugSkeletonOnly` in `planet.earth.settings.v2`, and both the switch and provider selector reserve `data-gatekeeper-permission="earth.motion_debug"`.
|
||||||
|
|
||||||
|
The Browser Camera provider's gesture pipeline lives in `recognizeGesture()` inside [motion-browser-provider.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-browser-provider.js). Detectors are evaluated in this order, first match wins:
|
||||||
|
|
||||||
|
1. **`getZoomTrend` (trend zoom)** — derived from the per-frame change in `Math.abs(rightWrist.x - leftWrist.x)`. Both wrists must cross the noise floor (`ZOOM_TREND_MIN_WRIST_DELTA = 0.010`) and stay within `ZOOM_TREND_HEIGHT_TOLERANCE` of each other vertically. Growing span → `zoom_in`, shrinking span → `zoom_out`. Trend has highest priority so mid-motion frames cannot be hijacked by the layer/focus/rotate detectors.
|
||||||
|
2. **`getZoomHoldPose` (sustained zoom)** — after motion stops, keeps emitting `zoom_in` while the wrists stay at chest level or above with span > `ZOOM_HOLD_SPREAD_FACTOR × shoulderWidth` (default 1.30), and `zoom_out` while elbows sit visibly outward and span < `ZOOM_HOLD_CLOSE_FACTOR × shoulderWidth` (default 0.85).
|
||||||
|
3. **layer / focus** — left-wrist raise + vertical motion fires `layer_prev/next`; head tilt fires `focus_prev/next`.
|
||||||
|
4. **`getRightArmPattern` (single-arm rotate)** — only considered when both `!isZoomCandidatePose(...)` and `isLeftArmAtRest(...)` hold. `isLeftArmAtRest` requires the left wrist to hang clearly below the shoulder line (≥ 0.13) and both left elbow and left wrist to stay near the body — any ambiguous left-arm posture (mid-spread, raised, held at chest) blocks single-arm rotate.
|
||||||
|
|
||||||
|
Two non-obvious decisions worth preserving:
|
||||||
|
|
||||||
|
- **Mirror-safe**: all zoom checks use `Math.abs(rightWrist.x - leftWrist.x)` and never rely on per-side x direction. `getUserMedia` returns the raw camera feed without horizontal flip, so a subject's anatomical left arm appears on the image right. A direction-based detector (e.g. "left wrist moves left, right wrist moves right") inverts on non-mirrored feeds — span-based detection is invariant.
|
||||||
|
- **Continuous vs. discrete**: `rotate`, `layer`, `focus`, and `confirm` go through `applyPoseLatch`, which emits each gesture once until the pose returns to neutral (one wave = one rotation step). Zoom intentionally bypasses the latch and re-matches every frame; downstream `GESTURE_POLICIES.zoom_in/out.cooldownMs = 120` rate-limits to ~8 emits/sec, so holding a spread pose keeps zooming in until the user changes their pose. Do not reuse the latch for zoom — that semantic difference is the point.
|
||||||
|
|
||||||
[presentation-controller.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/presentation-controller.js) is the new Presentation layer. In the first stage only Motion uses it: `motion-cruise-adapter.js` uses a persistent presentation that reuses the cruise fixed-card placement and connector, but mouse movement does not auto-hide the card. The connector recalculates source and target anchors every frame so dragged cards, globe rotation, and moving targets stay connected. BGP/News still use the existing `CruiseSequencer` auto-advance path to preserve the old cruise experience.
|
[presentation-controller.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/presentation-controller.js) is the new Presentation layer. In the first stage only Motion uses it: `motion-cruise-adapter.js` uses a persistent presentation that reuses the cruise fixed-card placement and connector, but mouse movement does not auto-hide the card. The connector recalculates source and target anchors every frame so dragged cards, globe rotation, and moving targets stay connected. BGP/News still use the existing `CruiseSequencer` auto-advance path to preserve the old cruise experience.
|
||||||
|
|
||||||
### 6. Globe and Terrain
|
### 6. Globe and Terrain
|
||||||
@@ -134,6 +146,10 @@ The compute-center layer row has a notification badge for GeoJSON `unresolved` r
|
|||||||
|
|
||||||
Location candidate state in the details card is cached in [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) by `entityType:entityId`. If the user closes the details card or unresolved queue and reopens the same compute center / BGP collector, previously collected candidates and status text are restored. Header-level `一键采用` prefers cached candidates, avoiding repeated online geocoding or LLM factcheck calls. After a location is saved, that entity's candidate list is cleared to a "refreshing layer" status so stale candidates do not keep misleading the user.
|
Location candidate state in the details card is cached in [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) by `entityType:entityId`. If the user closes the details card or unresolved queue and reopens the same compute center / BGP collector, previously collected candidates and status text are restored. Header-level `一键采用` prefers cached candidates, avoiding repeated online geocoding or LLM factcheck calls. After a location is saved, that entity's candidate list is cleared to a "refreshing layer" status so stale candidates do not keep misleading the user.
|
||||||
|
|
||||||
|
The `预览 / 保存` buttons on each candidate row use a single delegated `click` handler per candidate root (the `[data-collect-cache-key]` block in the details card, or `[data-unresolved-item]` in the unresolved queue), guarded by a `data-candidate-actions-bound` flag so it cannot be double-bound. Direct `pointerup` / `click` listeners on individual buttons and overlapping delegated handlers were removed. Candidate objects are no longer JSON-stringified into an HTML attribute and parsed back; buttons only carry `data-candidate-index`, and the handler resolves the candidate object from a module-level `Map` keyed by cache-key. This removes the entire class of failures caused by HTML entity escaping of `&` / `<` / `"` in candidate fields. Clicking `预览` dispatches `earth:preview-location-candidate`; `main.js`'s `previewLocationCandidate()` calls `showComputeCenterLocationPreview()`, which attaches a hollow breathing-ring sprite pair at the candidate coordinates (visually mirroring the BGP event ring) and focuses the camera on the candidate. Previewing another candidate replaces the ring; saving clears it and `spawnSavedComputeCenterLocation()` immediately spawns the formal compute-center interactable. Note that `main.js` has no module-level `earth` variable — every location-save / preview handler must call `const earth = getEarth();` first, otherwise the event handler throws a `ReferenceError` that the surrounding `.catch` swallows, producing the failure mode where the button "does nothing".
|
||||||
|
|
||||||
|
The `earth:compute-center-location-saved` reconciliation pipeline is deliberately silent on background-refresh failures. `spawnComputeCenterAfterLocationSave()` already presents the success toast and locked state; `refreshComputeCentersAfterLocationSave()` only reloads backend data when the scene is ready and no longer emits its own `已保存` toast. `handleComputeCenterLocationSaved()` runs refresh in the background after a successful spawn; only when spawn returns `null` (scene not ready) or throws does refresh take over the success toast. A refresh error is only `console.warn`'d — it must never surface as a `保存失败` message, because the save itself succeeded and the refresh is a follow-up sync.
|
||||||
|
|
||||||
### AIS Vessel Layer
|
### AIS Vessel Layer
|
||||||
|
|
||||||
The vessel layer fetches `/api/v1/visualization/geo/vessels` and renders the aggregated AIS GeoJSON through `createInteractableLayer()`. By default it does not send a `limit` parameter, and `VESSEL_CONFIG.maxRenderedMarkers = 0` means the frontend does not clip the result to 5000 vessels. A positive `options.limit` or positive `maxRenderedMarkers` can still be used as an explicit temporary cap.
|
The vessel layer fetches `/api/v1/visualization/geo/vessels` and renders the aggregated AIS GeoJSON through `createInteractableLayer()`. By default it does not send a `limit` parameter, and `VESSEL_CONFIG.maxRenderedMarkers = 0` means the frontend does not clip the result to 5000 vessels. A positive `options.limit` or positive `maxRenderedMarkers` can still be used as an explicit temporary cap.
|
||||||
|
|||||||
@@ -102,7 +102,15 @@ If both localhost checks pass but a phone or another computer cannot connect, st
|
|||||||
./planet.sh start --allow-lan
|
./planet.sh start --allow-lan
|
||||||
```
|
```
|
||||||
|
|
||||||
Then configure portproxy and firewall from Administrator PowerShell:
|
The flag must be written as `--allow-lan`. `allowlan` or `--allowlan` is not recognized by the startup script. If Planet is already running and you only need to reopen the frontend on the LAN, restart the frontend explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./planet.sh restart -f 3000 --allow-lan
|
||||||
|
```
|
||||||
|
|
||||||
|
If `ss -ltnp` shows the frontend listening on `0.0.0.0:3000`, but `Test-NetConnection <Windows LAN IP> -Port 3000` still fails from Windows PowerShell, the problem is usually Windows-side forwarding or firewall policy rather than Vite or `.zshrc`.
|
||||||
|
|
||||||
|
For traditional WSL NAT networking, configure portproxy and firewall from Administrator PowerShell:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
|
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
|
||||||
@@ -111,6 +119,20 @@ New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Al
|
|||||||
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
|
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If `wslinfo --networking-mode` prints `mirrored`, also check Hyper-V firewall. Even when ordinary Windows Firewall rules exist, Hyper-V firewall can still block external devices from reaching WSL. From Administrator PowerShell, allow the required ports:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-NetFirewallHyperVRule -Name "Planet-Frontend-3000" -DisplayName "Planet Frontend 3000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 3000 -Action Allow
|
||||||
|
New-NetFirewallHyperVRule -Name "Planet-Backend-8000" -DisplayName "Planet Backend 8000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 8000 -Action Allow
|
||||||
|
```
|
||||||
|
|
||||||
|
Use these commands to inspect the current Hyper-V firewall state:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-NetFirewallHyperVVMSetting -Name "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}"
|
||||||
|
Get-NetFirewallHyperVRule -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}"
|
||||||
|
```
|
||||||
|
|
||||||
LAN devices should open the Windows LAN IP, for example `http://<Windows LAN IP>:3000/earth`, not the internal WSL IP.
|
LAN devices should open the Windows LAN IP, for example `http://<Windows LAN IP>:3000/earth`, not the internal WSL IP.
|
||||||
|
|
||||||
### How do `--allow-lan` and the Motion Agent LAN URL fit together?
|
### How do `--allow-lan` and the Motion Agent LAN URL fit together?
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Current admin-related routes:
|
|||||||
- `/alerts/bgp`
|
- `/alerts/bgp`
|
||||||
- `/alerts/situational`
|
- `/alerts/situational`
|
||||||
- `/bgp`
|
- `/bgp`
|
||||||
- `/playground`
|
- `/ai`
|
||||||
- `/settings`
|
- `/settings`
|
||||||
|
|
||||||
`/earth` is a standalone display page and is not part of the console shell.
|
`/earth` is a standalone display page and is not part of the console shell.
|
||||||
@@ -196,7 +196,24 @@ Responsibilities:
|
|||||||
|
|
||||||
`App.tsx` uses it to decide whether to redirect to the login page. `/docs` remains a public route, but the backend decides the visible catalog and content from the token; anonymous visitors only receive public docs.
|
`App.tsx` uses it to decide whether to redirect to the login page. `/docs` remains a public route, but the backend decides the visible catalog and content from the token; anonymous visitors only receive public docs.
|
||||||
|
|
||||||
### 2. Business Data Gateway
|
### 2. AI
|
||||||
|
|
||||||
|
File:
|
||||||
|
|
||||||
|
- [AISettings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/AISettings/AISettings.tsx)
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- `/ai` now owns LLM Provider, AI Tool configuration, and the testbench instead of nesting them under `/settings`
|
||||||
|
- The `模型供应商` tab manages default provider, model, base URL, provider key, local `aiprovider` proxy, and connection test
|
||||||
|
- The `工具` tab manages WebSearch provider, search key, base URL, timeout, result count, and advanced provider options
|
||||||
|
- The `测试台` tab embeds the former Playground real session, preset prompts, and AI Provider status debugging
|
||||||
|
- The page reuses the Settings single-screen tabs, panel card, and internal scrolling style
|
||||||
|
|
||||||
|
Legacy `/settings?tab=ai` should redirect to `/ai?tab=providers`.
|
||||||
|
Legacy `/playground` should redirect to `/ai?tab=playground`.
|
||||||
|
|
||||||
|
### 3. Business Data Gateway
|
||||||
|
|
||||||
AI / situational awareness related services are currently in:
|
AI / situational awareness related services are currently in:
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ After a default startup, the common URLs are:
|
|||||||
| Docs | `http://localhost:3000/docs` | Partly | Usage docs are public; developer, backend, and operations docs require Gatekeeper groups |
|
| Docs | `http://localhost:3000/docs` | Partly | Usage docs are public; developer, backend, and operations docs require Gatekeeper groups |
|
||||||
| FAQ | `http://localhost:3000/docs/faq` | No | Windows / WSL, ports, dependencies, motion capture, credentials, and permission troubleshooting |
|
| FAQ | `http://localhost:3000/docs/faq` | No | Windows / WSL, ports, dependencies, motion capture, credentials, and permission troubleshooting |
|
||||||
| Console | `http://localhost:3000/admin` | Yes | Data, config, alerts, logs, and situational observation |
|
| Console | `http://localhost:3000/admin` | Yes | Data, config, alerts, logs, and situational observation |
|
||||||
| AI Playground | `http://localhost:3000/playground` | Yes | AI Provider status and debugging |
|
| AI | `http://localhost:3000/ai` | Yes | Model providers, AI tools, and testbench |
|
||||||
| Backend API Docs | `http://localhost:8000/docs` | Depends on endpoint | FastAPI / OpenAPI documentation |
|
| Backend API Docs | `http://localhost:8000/docs` | Depends on endpoint | FastAPI / OpenAPI documentation |
|
||||||
|
|
||||||
## planet.sh
|
## planet.sh
|
||||||
@@ -427,7 +427,7 @@ Common pages:
|
|||||||
| System Alerts | `/alerts/system` | System-level alerts |
|
| System Alerts | `/alerts/system` | System-level alerts |
|
||||||
| BGP Alerts | `/alerts/bgp` | BGP-related alerts |
|
| BGP Alerts | `/alerts/bgp` | BGP-related alerts |
|
||||||
| Situational Alerts | `/alerts/situational` | Situational assessment alerts |
|
| Situational Alerts | `/alerts/situational` | Situational assessment alerts |
|
||||||
| AI Playground | `/playground` | AI Provider debugging |
|
| AI | `/ai` | Model providers, WebSearch-style tools, and testbench |
|
||||||
| System Logs | `/logs` | View system logs (typically super admin only) |
|
| System Logs | `/logs` | View system logs (typically super admin only) |
|
||||||
| Users | `/users` | User management |
|
| Users | `/users` | User management |
|
||||||
| Settings | `/settings` | System config and TV live stream sources |
|
| Settings | `/settings` | System config and TV live stream sources |
|
||||||
@@ -487,7 +487,18 @@ Current common uses:
|
|||||||
- System settings
|
- System settings
|
||||||
- TV live stream source configuration
|
- TV live stream source configuration
|
||||||
- Collector settings
|
- Collector settings
|
||||||
- External integrations and AI Provider configuration
|
|
||||||
|
### AI
|
||||||
|
|
||||||
|
`/ai` manages the AI runtime chain and is now separate from system settings. Legacy `/playground` redirects to `/ai?tab=playground`.
|
||||||
|
|
||||||
|
It currently contains:
|
||||||
|
|
||||||
|
- `模型供应商`: default LLM provider, model, base URL, API key, local `aiprovider` proxy, and connection test
|
||||||
|
- `工具`: WebSearch provider, search API key, base URL, max results, timeout, and advanced provider options
|
||||||
|
- `测试台`: AI Provider status, preset prompts, and real analysis-chain debugging
|
||||||
|
|
||||||
|
Legacy `/settings?tab=ai` redirects to `/ai?tab=providers`.
|
||||||
|
|
||||||
Available configuration depends on the current user's role.
|
Available configuration depends on the current user's role.
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ After startup, the key URLs are:
|
|||||||
| Earth | `http://localhost:3000/earth` | Public 3D Earth visualization |
|
| Earth | `http://localhost:3000/earth` | Public 3D Earth visualization |
|
||||||
| Console | `http://localhost:3000/admin` | Admin console (login required) |
|
| Console | `http://localhost:3000/admin` | Admin console (login required) |
|
||||||
| Docs | `http://localhost:3000/docs` | Usage docs are public; developer and operations docs require Gatekeeper groups |
|
| Docs | `http://localhost:3000/docs` | Usage docs are public; developer and operations docs require Gatekeeper groups |
|
||||||
| AI Playground | `http://localhost:3000/playground` | AI debugging (login required) |
|
| AI | `http://localhost:3000/ai` | Model provider, tool, and testbench entry (login required) |
|
||||||
| Backend API Docs | `http://localhost:8000/docs` | FastAPI / OpenAPI interface docs |
|
| Backend API Docs | `http://localhost:8000/docs` | FastAPI / OpenAPI interface docs |
|
||||||
|
|
||||||
If the default ports are taken, specify custom ports:
|
If the default ports are taken, specify custom ports:
|
||||||
@@ -114,6 +114,7 @@ First-time inspection checklist:
|
|||||||
- `/datasources`: data source directory and collection triggers; endpoint, headers, and credentials are configured under `/settings` collector settings
|
- `/datasources`: data source directory and collection triggers; endpoint, headers, and credentials are configured under `/settings` collector settings
|
||||||
- `/data`: collected data
|
- `/data`: collected data
|
||||||
- `/bgp`: BGP situational view
|
- `/bgp`: BGP situational view
|
||||||
|
- `/ai`: AI page for model providers, WebSearch-style tools, and the testbench
|
||||||
- `/alerts/system`: system alerts
|
- `/alerts/system`: system alerts
|
||||||
- `/settings`: system configuration
|
- `/settings`: system configuration
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,18 @@ React 路由入口:
|
|||||||
|
|
||||||
动捕调试面板由 [motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) 负责。它监听 `earth:motion-debug-frame`,用 canvas 绘制归一化骨架点和连线;Browser Camera provider 会额外通过 `earth:motion-debug-video-source` 提供本机 `<video>` 作为调试预览底图,`shared.motionDebugSkeletonOnly` 可切换为只显示骨骼。`停止匹配动作` 通过 `earth:motion-recognition-pause` 暂停 gesture 执行,但继续显示视频和骨架。未匹配动作为红色,匹配后变绿并显示动作名。设置项持久化在 `planet.earth.settings.v2` 的 `shared.motionDebugEnabled`、`shared.motionProvider` 与 `shared.motionDebugSkeletonOnly`,switch 和输入源控件都预留 `data-gatekeeper-permission="earth.motion_debug"`。
|
动捕调试面板由 [motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) 负责。它监听 `earth:motion-debug-frame`,用 canvas 绘制归一化骨架点和连线;Browser Camera provider 会额外通过 `earth:motion-debug-video-source` 提供本机 `<video>` 作为调试预览底图,`shared.motionDebugSkeletonOnly` 可切换为只显示骨骼。`停止匹配动作` 通过 `earth:motion-recognition-pause` 暂停 gesture 执行,但继续显示视频和骨架。未匹配动作为红色,匹配后变绿并显示动作名。设置项持久化在 `planet.earth.settings.v2` 的 `shared.motionDebugEnabled`、`shared.motionProvider` 与 `shared.motionDebugSkeletonOnly`,switch 和输入源控件都预留 `data-gatekeeper-permission="earth.motion_debug"`。
|
||||||
|
|
||||||
|
Browser Camera provider 的手势识别管线在 [motion-browser-provider.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-browser-provider.js) 的 `recognizeGesture()`,按以下顺序匹配,前者命中即返回:
|
||||||
|
|
||||||
|
1. **`getZoomTrend`(趋势 zoom)**:基于上一帧到当前帧两腕 x 间距的变化量(`Math.abs(rightWrist.x - leftWrist.x)` 的 delta)。两腕都越过噪声地板(`ZOOM_TREND_MIN_WRIST_DELTA = 0.010`)且高度差不超过 `ZOOM_TREND_HEIGHT_TOLERANCE`,spread 增加 → `zoom_in`,spread 减少 → `zoom_out`。趋势优先级最高,避免中间帧被 layer/focus/rotate 抢先误判。
|
||||||
|
2. **`getZoomHoldPose`(姿态 zoom)**:动作停止后,只要两腕仍保持在胸口及以上、且 span > `ZOOM_HOLD_SPREAD_FACTOR × shoulderWidth`(默认 1.30)就持续派发 `zoom_in`;两肘明显外展且 span < `ZOOM_HOLD_CLOSE_FACTOR × shoulderWidth`(默认 0.85)就持续派发 `zoom_out`。
|
||||||
|
3. **layer / focus**:左手抬起 + 上下移动派发 `layer_prev/next`;头部左右倾斜派发 `focus_prev/next`。
|
||||||
|
4. **`getRightArmPattern`(单臂 rotate)**:仅当 `!isZoomCandidatePose(...) && isLeftArmAtRest(...)` 同时成立时才考虑。`isLeftArmAtRest` 要求左腕明显垂在肩下 13% 以下且左肘/左腕都不外伸,把「张臂中间帧」「单手举起」「左手扶在胸前」等所有模糊状态都判为非静止 —— 单臂 rotate 严格要求另一只手处于静止。
|
||||||
|
|
||||||
|
两个关键设计:
|
||||||
|
|
||||||
|
- **mirror-safe**:所有 zoom 检测都基于 `Math.abs(rightWrist.x - leftWrist.x)`,不依赖单侧 x 方向。无论摄像头是否做镜像翻转(浏览器默认不翻转,`getUserMedia` 返回的就是原始帧),张臂始终 → zoom_in,合手始终 → zoom_out。早期基于「左腕往左 + 右腕往右」的判定会在非镜像视图里把方向判反。
|
||||||
|
- **连续 vs 离散**:rotate / layer / focus / confirm 都过 `applyPoseLatch`,同一手势只发一次,必须先回到中性位才能再发(挥一下转一格)。zoom 故意 bypass latch,每帧匹配都返回 → 下游 `GESTURE_POLICIES.zoom_in/out.cooldownMs = 120` 节流到 ~8 次/秒,张臂保持就一直放大直到姿势变化。这是 zoom 跟其它手势在交互语义上的本质区别,不要复用 latch 给 zoom。
|
||||||
|
|
||||||
[presentation-controller.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/presentation-controller.js) 是新的 Presentation 层。第一阶段只接入 Motion:`motion-cruise-adapter.js` 通过 persistent presentation 复用巡航固定卡片位置和 connector,但不会让鼠标移动触发自动隐藏;connector 每帧重算 source/target anchor,让卡片拖动、地球旋转和目标移动时端点继续跟随。BGP/News 仍保持原有 `CruiseSequencer` 自动轮播路径,避免改变既有巡航体验。
|
[presentation-controller.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/presentation-controller.js) 是新的 Presentation 层。第一阶段只接入 Motion:`motion-cruise-adapter.js` 通过 persistent presentation 复用巡航固定卡片位置和 connector,但不会让鼠标移动触发自动隐藏;connector 每帧重算 source/target anchor,让卡片拖动、地球旋转和目标移动时端点继续跟随。BGP/News 仍保持原有 `CruiseSequencer` 自动轮播路径,避免改变既有巡航体验。
|
||||||
|
|
||||||
### 6. 地球与地形
|
### 6. 地球与地形
|
||||||
@@ -330,6 +342,10 @@ AISStream 的 `PositionReport` 常带实时位置和 `MetaData.ShipName`,但
|
|||||||
|
|
||||||
详情卡里的坐标候选状态由 [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) 按 `entityType:entityId` 缓存在模块内存中。用户关闭详情卡或待定位列表后再次打开同一个算力中心 / BGP 观测站,已经采集到的候选和状态文案会恢复;`一键采用` 会优先使用缓存候选,避免重复调用在线地理编码或 LLM factcheck。保存成功后该实体的候选列表会清空为“正在刷新图层”状态,避免旧候选在刷新后继续误导用户。
|
详情卡里的坐标候选状态由 [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) 按 `entityType:entityId` 缓存在模块内存中。用户关闭详情卡或待定位列表后再次打开同一个算力中心 / BGP 观测站,已经采集到的候选和状态文案会恢复;`一键采用` 会优先使用缓存候选,避免重复调用在线地理编码或 LLM factcheck。保存成功后该实体的候选列表会清空为“正在刷新图层”状态,避免旧候选在刷新后继续误导用户。
|
||||||
|
|
||||||
|
候选行的 `预览 / 保存` 按钮采用单一的事件委托模型:每个候选根(详情卡里的 `[data-collect-cache-key]` 块,或待定位列表里的 `[data-unresolved-item]`)只挂一个 `click` 监听,由 `data-candidate-actions-bound` 幂等标记,不再混用 `pointerup` / `click` 直绑或重复委托。候选对象不再以 JSON 字符串塞进 HTML 属性后再 `JSON.parse`,按钮只携带 `data-candidate-index`,handler 通过 cache-key 在模块内存的 `Map` 里取出原对象,避开 HTML 实体转义对 `&` / `<` / `"` 的破坏。点击 `预览` 会派发 `earth:preview-location-candidate`,由 `main.js` 的 `previewLocationCandidate()` 调用 `showComputeCenterLocationPreview()`:在候选经纬度上挂双层空心呼吸 sprite(视觉参考 BGP 事件 ring),并把视角聚焦到候选坐标;切换到另一个候选会替换为新呼吸圈,保存时立即清除并由 `spawnSavedComputeCenterLocation()` 即时生成正式算力中心交互图标。注意 `main.js` 没有模块级 `earth` 变量,所有 location-save / preview 处理函数必须先 `const earth = getEarth();`,否则会在事件 handler 里抛 `ReferenceError` 被 `.catch` 静默掉,外观上等同于按钮“没有反应”。
|
||||||
|
|
||||||
|
`earth:compute-center-location-saved` 之后的图层校准链路对后台刷新失败保持沉默:`spawnComputeCenterAfterLocationSave()` 已经把 toast 和 locked 状态都给了用户,`refreshComputeCentersAfterLocationSave()` 只在场景就绪时重新拉取后端数据,本身不再吐 `已保存` toast;`handleComputeCenterLocationSaved()` 在 spawn 成功路径让 refresh 静默后台运行,只在 spawn 返回 `null`(场景未就绪)或抛错时才让 refresh 接管成功 toast,refresh 自身报错只走 `console.warn`,绝不冒泡成 `保存失败` 文案——保存请求本身已经成功,刷新失败属于后续同步问题。
|
||||||
|
|
||||||
asset 图标大小由 `Interactable` 的 `icon.fitSize` 控制。SVG / 图片文件应尽量保持原始 viewBox 和路径,不要为了在地球上显示成 60x60 而手写 `transform`;`drawAssetIcon()` 会把资源等比 contain 到指定尺寸并居中绘制到 atlas canvas。
|
asset 图标大小由 `Interactable` 的 `icon.fitSize` 控制。SVG / 图片文件应尽量保持原始 viewBox 和路径,不要为了在地球上显示成 60x60 而手写 `transform`;`drawAssetIcon()` 会把资源等比 contain 到指定尺寸并居中绘制到 atlas canvas。
|
||||||
|
|
||||||
`Interactable` 默认使用固定屏幕像素尺寸,适合船只、BGP 事件、BGP 观测站、算力中心这类需要稳定识别的图标。如果某类图标需要跟随相机距离缩放,可以把 `sizeMode` 设为非 `"fixed"`,并用 `sizeScale.min / max / referenceFov` 控制缩放范围;单个 marker 的业务尺寸差异可以通过 `getPointSizeMultiplier()` 表达,例如 BGP 事件按严重级别调整点大小,BGP 观测站按活跃度调整点大小。
|
`Interactable` 默认使用固定屏幕像素尺寸,适合船只、BGP 事件、BGP 观测站、算力中心这类需要稳定识别的图标。如果某类图标需要跟随相机距离缩放,可以把 `sizeMode` 设为非 `"fixed"`,并用 `sizeScale.min / max / referenceFov` 控制缩放范围;单个 marker 的业务尺寸差异可以通过 `getPointSizeMultiplier()` 表达,例如 BGP 事件按严重级别调整点大小,BGP 观测站按活跃度调整点大小。
|
||||||
|
|||||||
@@ -104,7 +104,15 @@ curl http://localhost:8000/health
|
|||||||
./planet.sh start --allow-lan
|
./planet.sh start --allow-lan
|
||||||
```
|
```
|
||||||
|
|
||||||
管理员 PowerShell 中配置 portproxy 和防火墙:
|
参数必须写成 `--allow-lan`。`allowlan` 或 `--allowlan` 不会被启动脚本识别。如果服务已经启动,只想重新开放前端,需要显式重启前端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./planet.sh restart -f 3000 --allow-lan
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `ss -ltnp` 显示前端已经监听 `0.0.0.0:3000`,但 Windows PowerShell 中 `Test-NetConnection <Windows局域网IP> -Port 3000` 仍失败,问题通常不在 Vite 或 `.zshrc`,而是在 Windows 侧转发或防火墙。
|
||||||
|
|
||||||
|
传统 WSL NAT 场景下,管理员 PowerShell 中配置 portproxy 和防火墙:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
|
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
|
||||||
@@ -113,6 +121,20 @@ New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Al
|
|||||||
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
|
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果 `wslinfo --networking-mode` 输出 `mirrored`,还需要检查 Hyper-V firewall。普通 Windows 防火墙规则存在时,Hyper-V firewall 仍可能拦截外部设备进入 WSL。管理员 PowerShell 中按端口放行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-NetFirewallHyperVRule -Name "Planet-Frontend-3000" -DisplayName "Planet Frontend 3000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 3000 -Action Allow
|
||||||
|
New-NetFirewallHyperVRule -Name "Planet-Backend-8000" -DisplayName "Planet Backend 8000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 8000 -Action Allow
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以用下面命令确认当前 Hyper-V firewall 状态:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-NetFirewallHyperVVMSetting -Name "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}"
|
||||||
|
Get-NetFirewallHyperVRule -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}"
|
||||||
|
```
|
||||||
|
|
||||||
局域网设备访问的是 Windows 的局域网 IP,例如 `http://<Windows局域网IP>:3000/earth`,不是 WSL 内部 IP。
|
局域网设备访问的是 Windows 的局域网 IP,例如 `http://<Windows局域网IP>:3000/earth`,不是 WSL 内部 IP。
|
||||||
|
|
||||||
### `--allow-lan` 和 Motion Agent 局域网地址怎么配?
|
### `--allow-lan` 和 Motion Agent 局域网地址怎么配?
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
- `/alerts/bgp`
|
- `/alerts/bgp`
|
||||||
- `/alerts/situational`
|
- `/alerts/situational`
|
||||||
- `/bgp`
|
- `/bgp`
|
||||||
- `/playground`
|
- `/ai`
|
||||||
- `/settings`
|
- `/settings`
|
||||||
|
|
||||||
`/earth` 是独立展示页,不属于控制台骨架。
|
`/earth` 是独立展示页,不属于控制台骨架。
|
||||||
@@ -196,7 +196,24 @@
|
|||||||
|
|
||||||
`App.tsx` 用它判断是否进入登录页。`/docs` 仍是公开路由,但目录和正文由后端按 token 决定;未登录时只返回公开文档。
|
`App.tsx` 用它判断是否进入登录页。`/docs` 仍是公开路由,但目录和正文由后端按 token 决定;未登录时只返回公开文档。
|
||||||
|
|
||||||
### 2. 业务数据网关
|
### 2. AI
|
||||||
|
|
||||||
|
文件:
|
||||||
|
|
||||||
|
- [AISettings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/AISettings/AISettings.tsx)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- `/ai` 独立承载 LLM Provider、AI Tool 配置和测试台,不再放在 `/settings` 的系统配置 tabs 中
|
||||||
|
- `模型供应商` tab 管理默认 provider、模型、base URL、provider key、本地 `aiprovider` 代理和连接测试
|
||||||
|
- `工具` tab 管理 WebSearch provider、搜索 key、base URL、超时、结果数和高级 provider 参数
|
||||||
|
- `测试台` tab 嵌入原 Playground 的真实会话、预设请求和 AI Provider 状态调试
|
||||||
|
- 页面复用 Settings 的单屏 tabs、panel card 和内部滚动样式
|
||||||
|
|
||||||
|
旧的 `/settings?tab=ai` 应跳转到 `/ai?tab=providers`。
|
||||||
|
旧的 `/playground` 应跳转到 `/ai?tab=playground`。
|
||||||
|
|
||||||
|
### 3. 业务数据网关
|
||||||
|
|
||||||
目前 AI / 态势感知相关服务集中在:
|
目前 AI / 态势感知相关服务集中在:
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
| Docs | `http://localhost:3000/docs` | 部分需要 | 使用手册公开;开发、后端、运维文档按 Gatekeeper 权限组开放 |
|
| Docs | `http://localhost:3000/docs` | 部分需要 | 使用手册公开;开发、后端、运维文档按 Gatekeeper 权限组开放 |
|
||||||
| FAQ | `http://localhost:3000/docs/faq` | 否 | Windows / WSL、端口、依赖、动捕、凭证和权限排障 |
|
| FAQ | `http://localhost:3000/docs/faq` | 否 | Windows / WSL、端口、依赖、动捕、凭证和权限排障 |
|
||||||
| 控制台 | `http://localhost:3000/admin` | 是 | 数据、配置、告警、日志和专题观测 |
|
| 控制台 | `http://localhost:3000/admin` | 是 | 数据、配置、告警、日志和专题观测 |
|
||||||
| AI Playground | `http://localhost:3000/playground` | 是 | AI Provider 状态和调试 |
|
| AI | `http://localhost:3000/ai` | 是 | 模型供应商、AI 工具和测试台 |
|
||||||
| 后端 API 文档 | `http://localhost:8000/docs` | 视接口而定 | FastAPI / OpenAPI 文档 |
|
| 后端 API 文档 | `http://localhost:8000/docs` | 视接口而定 | FastAPI / OpenAPI 文档 |
|
||||||
|
|
||||||
## planet.sh
|
## planet.sh
|
||||||
@@ -458,7 +458,7 @@ http://localhost:3000/admin
|
|||||||
| 系统告警 | `/alerts/system` | 系统级告警 |
|
| 系统告警 | `/alerts/system` | 系统级告警 |
|
||||||
| BGP 告警 | `/alerts/bgp` | BGP 相关告警 |
|
| BGP 告警 | `/alerts/bgp` | BGP 相关告警 |
|
||||||
| 态势告警 | `/alerts/situational` | 态势研判告警 |
|
| 态势告警 | `/alerts/situational` | 态势研判告警 |
|
||||||
| AI Playground | `/playground` | AI Provider 调试 |
|
| AI | `/ai` | 模型供应商、WebSearch 等工具和测试台 |
|
||||||
| 系统日志 | `/logs` | 查看系统日志,通常仅 super admin 可见 |
|
| 系统日志 | `/logs` | 查看系统日志,通常仅 super admin 可见 |
|
||||||
| 用户管理 | `/users` | 管理用户 |
|
| 用户管理 | `/users` | 管理用户 |
|
||||||
| 系统配置 | `/settings` | 系统配置和电视直播源等设置 |
|
| 系统配置 | `/settings` | 系统配置和电视直播源等设置 |
|
||||||
@@ -518,7 +518,18 @@ http://localhost:3000/admin
|
|||||||
- 系统设置
|
- 系统设置
|
||||||
- 电视直播源配置
|
- 电视直播源配置
|
||||||
- 采集器设置
|
- 采集器设置
|
||||||
- 外部集成和 AI Provider 配置
|
|
||||||
|
### AI
|
||||||
|
|
||||||
|
`/ai` 用于管理 AI 运行链路,已经从系统配置中独立出来。旧链接 `/playground` 会跳转到 `/ai?tab=playground`。
|
||||||
|
|
||||||
|
当前包含:
|
||||||
|
|
||||||
|
- `模型供应商`:默认 LLM provider、模型、Base URL、API Key、本地 `aiprovider` 代理和连接测试
|
||||||
|
- `工具`:WebSearch provider、搜索 API Key、Base URL、最大结果数、超时和高级 provider 参数
|
||||||
|
- `测试台`:AI Provider 状态、预设请求和真实分析链路调试
|
||||||
|
|
||||||
|
旧链接 `/settings?tab=ai` 会跳转到 `/ai?tab=providers`。
|
||||||
|
|
||||||
具体可用配置取决于当前登录用户权限。
|
具体可用配置取决于当前登录用户权限。
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export BARENTSWATCH_CLIENT_SECRET="..."
|
|||||||
| Earth | `http://localhost:3000/earth` | 公开 3D Earth 可视化页面 |
|
| Earth | `http://localhost:3000/earth` | 公开 3D Earth 可视化页面 |
|
||||||
| 控制台 | `http://localhost:3000/admin` | 登录后的管理后台 |
|
| 控制台 | `http://localhost:3000/admin` | 登录后的管理后台 |
|
||||||
| 文档站 | `http://localhost:3000/docs` | 使用手册公开;开发/运维文档按 Gatekeeper 权限组开放 |
|
| 文档站 | `http://localhost:3000/docs` | 使用手册公开;开发/运维文档按 Gatekeeper 权限组开放 |
|
||||||
| AI Playground | `http://localhost:3000/playground` | 登录后的 AI 调试入口 |
|
| AI | `http://localhost:3000/ai` | 登录后的模型供应商、工具和测试台入口 |
|
||||||
| 后端 API 文档 | `http://localhost:8000/docs` | FastAPI / OpenAPI 接口文档 |
|
| 后端 API 文档 | `http://localhost:8000/docs` | FastAPI / OpenAPI 接口文档 |
|
||||||
|
|
||||||
如果默认端口被占用,可以指定端口:
|
如果默认端口被占用,可以指定端口:
|
||||||
@@ -114,6 +114,7 @@ http://localhost:3000/admin
|
|||||||
- `/datasources`:数据源目录和采集触发;接口、请求头和凭证配置在 `/settings` 的“采集器设置”
|
- `/datasources`:数据源目录和采集触发;接口、请求头和凭证配置在 `/settings` 的“采集器设置”
|
||||||
- `/data`:已采集数据
|
- `/data`:已采集数据
|
||||||
- `/bgp`:BGP 专题观测
|
- `/bgp`:BGP 专题观测
|
||||||
|
- `/ai`:AI,管理模型供应商、WebSearch 等工具和测试台
|
||||||
- `/alerts/system`:系统告警
|
- `/alerts/system`:系统告警
|
||||||
- `/settings`:系统配置
|
- `/settings`:系统配置
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,13 @@
|
|||||||
## Current Version
|
## Current Version
|
||||||
|
|
||||||
- `main` 当前主线历史推导到:`0.16.5`
|
- `main` 当前主线历史推导到:`0.16.5`
|
||||||
- `dev` 当前开发分支历史推导到:`0.50.0`
|
- `dev` 当前开发分支历史推导到:`0.51.0`
|
||||||
|
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
| Version | Type | Branch | Commit | Summary |
|
| Version | Type | Branch | Commit | Summary |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `0.51.0` | feature | `dev` | `pending` | 新增 AI Settings 控制台与 ai_tools 工具层;重写算力中心候选预览/保存交互(呼吸圈 + 即时图标);动作捕捉 zoom 改为 mirror-safe trend + pose hold 双通道,支持持续触发 |
|
||||||
| `0.50.0` | feature | `dev` | `pending` | 新增 Earth 动捕双通道控制、Motion Agent、Presentation 持久展示、AI Provider 多 provider 设置、位置候选 LLM 兜底与 FAQ |
|
| `0.50.0` | feature | `dev` | `pending` | 新增 Earth 动捕双通道控制、Motion Agent、Presentation 持久展示、AI Provider 多 provider 设置、位置候选 LLM 兜底与 FAQ |
|
||||||
| `0.49.0` | feature | `dev` | `pending` | 新增位置解析 Pipeline、BGP/算力中心地理定位、Docs Gatekeeper、Earth 新闻栏与 Mobile 国家高亮 |
|
| `0.49.0` | feature | `dev` | `pending` | 新增位置解析 Pipeline、BGP/算力中心地理定位、Docs Gatekeeper、Earth 新闻栏与 Mobile 国家高亮 |
|
||||||
| `0.48.0` | feature | `dev` | `pending` | 新增自定义源 REST/WebSocket 实时 mock 链路,完善 AIS 多源聚合/船舶 enrichment,并将 Earth 全球态势统计改为轻量 SQL 聚合 |
|
| `0.48.0` | feature | `dev` | `pending` | 新增自定义源 REST/WebSocket 实时 mock 链路,完善 AIS 多源聚合/船舶 enrichment,并将 Earth 全球态势统计改为轻量 SQL 聚合 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planet-frontend",
|
"name": "planet-frontend",
|
||||||
"version": "0.50.0",
|
"version": "0.51.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1",
|
"packageManager": "bun@1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#38bdf8" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#38bdf8" class="bi bi-pc" viewBox="0 0 16 16">
|
||||||
<path d="M1.5 0A1.5 1.5 0 0 0 0 1.5v7A1.5 1.5 0 0 0 1.5 10H6v1H1a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-5v-1h4.5A1.5 1.5 0 0 0 16 8.5v-7A1.5 1.5 0 0 0 14.5 0h-13Zm0 1h13a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-13a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5ZM12 12.5a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm2 0a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0ZM1.5 12h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1 0-1ZM1 14.25a.25.25 0 0 1 .25-.25h5.5a.25.25 0 1 1 0 .5h-5.5a.25.25 0 0 1-.25-.25Z"/>
|
<path d="M5 0a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1H5Zm.5 14a.5.5 0 1 1 0 1 .5.5 0 0 1 0-1Zm2 0a.5.5 0 1 1 0 1 .5.5 0 0 1 0-1ZM5 1.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5ZM5.5 3h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1 0-1Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 383 B |
@@ -879,20 +879,40 @@
|
|||||||
<button type="button" class="earth-mobile-settings-pill is-active" data-satellite-display-style="ground_footprint" aria-pressed="true">真实地表覆盖</button>
|
<button type="button" class="earth-mobile-settings-pill is-active" data-satellite-display-style="ground_footprint" aria-pressed="true">真实地表覆盖</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="earth-mobile-settings-card">
|
<label class="earth-mobile-settings-card">
|
||||||
<div class="earth-mobile-settings-copy">
|
<div class="earth-mobile-settings-copy">
|
||||||
<span class="earth-mobile-settings-label">日夜模式</span>
|
<span class="earth-mobile-settings-label">卫星呼吸闪烁</span>
|
||||||
<span class="earth-mobile-settings-subtitle">按真实太阳位置区分地球昼夜明暗</span>
|
<span class="earth-mobile-settings-subtitle">空闲时让卫星点缓慢明暗呼吸</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="earth-mobile-settings-switch">
|
<span class="earth-mobile-settings-switch">
|
||||||
<input type="checkbox" data-daynight-toggle checked>
|
<input type="checkbox" data-satellite-idle-breathing-toggle checked>
|
||||||
<span class="earth-mobile-settings-switch-track"></span>
|
<span class="earth-mobile-settings-switch-track"></span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label
|
<label class="earth-mobile-settings-card">
|
||||||
class="earth-mobile-settings-card"
|
<div class="earth-mobile-settings-copy">
|
||||||
data-gatekeeper-permission="earth.motion_debug"
|
<span class="earth-mobile-settings-label">低缩放圆点</span>
|
||||||
>
|
<span class="earth-mobile-settings-subtitle">150% 以下将可交互图标简化为对应颜色圆点</span>
|
||||||
|
</div>
|
||||||
|
<span class="earth-mobile-settings-switch">
|
||||||
|
<input type="checkbox" data-interactable-compact-dots-toggle checked>
|
||||||
|
<span class="earth-mobile-settings-switch-track"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="earth-mobile-settings-card">
|
||||||
|
<div class="earth-mobile-settings-copy">
|
||||||
|
<span class="earth-mobile-settings-label">日夜模式</span>
|
||||||
|
<span class="earth-mobile-settings-subtitle">按真实太阳位置区分地球昼夜明暗</span>
|
||||||
|
</div>
|
||||||
|
<span class="earth-mobile-settings-switch">
|
||||||
|
<input type="checkbox" data-daynight-toggle checked>
|
||||||
|
<span class="earth-mobile-settings-switch-track"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
class="earth-mobile-settings-card"
|
||||||
|
data-gatekeeper-permission="earth.motion_debug"
|
||||||
|
>
|
||||||
<div class="earth-mobile-settings-copy">
|
<div class="earth-mobile-settings-copy">
|
||||||
<span class="earth-mobile-settings-label">动捕调试模式</span>
|
<span class="earth-mobile-settings-label">动捕调试模式</span>
|
||||||
<span class="earth-mobile-settings-subtitle">显示摄像头识别到的骨架连线和匹配动作</span>
|
<span class="earth-mobile-settings-subtitle">显示摄像头识别到的骨架连线和匹配动作</span>
|
||||||
@@ -1160,6 +1180,26 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="earth-settings-item" for="toggle-satellite-idle-breathing">
|
||||||
|
<div class="earth-settings-copy">
|
||||||
|
<span class="earth-settings-item-title">卫星呼吸闪烁</span>
|
||||||
|
<span class="earth-settings-item-subtitle">空闲时让卫星点缓慢明暗呼吸,拖拽或缩放时自动稳定显示</span>
|
||||||
|
</div>
|
||||||
|
<span class="earth-settings-switch">
|
||||||
|
<input id="toggle-satellite-idle-breathing" type="checkbox" data-satellite-idle-breathing-toggle checked>
|
||||||
|
<span class="earth-settings-switch-track"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="earth-settings-item" for="toggle-interactable-compact-dots">
|
||||||
|
<div class="earth-settings-copy">
|
||||||
|
<span class="earth-settings-item-title">低缩放圆点</span>
|
||||||
|
<span class="earth-settings-item-subtitle">150% 以下将可交互图标简化为对应颜色圆点,便于巡航和总览扫视</span>
|
||||||
|
</div>
|
||||||
|
<span class="earth-settings-switch">
|
||||||
|
<input id="toggle-interactable-compact-dots" type="checkbox" data-interactable-compact-dots-toggle checked>
|
||||||
|
<span class="earth-settings-switch-track"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label class="earth-settings-item" for="toggle-daynight">
|
<label class="earth-settings-item" for="toggle-daynight">
|
||||||
<div class="earth-settings-copy">
|
<div class="earth-settings-copy">
|
||||||
<span class="earth-settings-item-title">日夜模式</span>
|
<span class="earth-settings-item-title">日夜模式</span>
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ function getMarkerTimestamp(marker) {
|
|||||||
|
|
||||||
export function createBGPCruiseAdapter({
|
export function createBGPCruiseAdapter({
|
||||||
camera,
|
camera,
|
||||||
|
earth,
|
||||||
getMarkers,
|
getMarkers,
|
||||||
connector,
|
connector,
|
||||||
focusView,
|
focusView,
|
||||||
@@ -108,7 +109,13 @@ export function createBGPCruiseAdapter({
|
|||||||
function getMarkerScreenCoords(marker) {
|
function getMarkerScreenCoords(marker) {
|
||||||
if (!marker || !camera) return null;
|
if (!marker || !camera) return null;
|
||||||
scratchBGPWorldPosition.copy(marker.position);
|
scratchBGPWorldPosition.copy(marker.position);
|
||||||
marker.parent?.localToWorld(scratchBGPWorldPosition);
|
if (marker.parent) {
|
||||||
|
marker.parent.localToWorld(scratchBGPWorldPosition);
|
||||||
|
} else {
|
||||||
|
const earthObject = typeof earth === "function" ? earth() : earth;
|
||||||
|
earthObject?.updateMatrixWorld(true);
|
||||||
|
earthObject?.localToWorld(scratchBGPWorldPosition);
|
||||||
|
}
|
||||||
return projectWorldToScreen(scratchBGPWorldPosition, camera);
|
return projectWorldToScreen(scratchBGPWorldPosition, camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { COMPUTE_CENTER_CONFIG, PATHS } from "./constants.js";
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js";
|
||||||
import {
|
import {
|
||||||
createInteractableLayer,
|
createInteractableLayer,
|
||||||
SURFACE_AVOIDANCE_PROFILES,
|
SURFACE_AVOIDANCE_PROFILES,
|
||||||
} from "./interactable.js";
|
} from "./interactable.js";
|
||||||
|
import { latLonToVector3 } from "./utils.js";
|
||||||
|
|
||||||
const COMPUTE_CENTER_RENDER_ORDER = 4.5;
|
const COMPUTE_CENTER_RENDER_ORDER = 4.5;
|
||||||
const COMPUTE_CENTER_POINT_SIZE = 36;
|
const COMPUTE_CENTER_POINT_SIZE = 36;
|
||||||
@@ -17,9 +20,84 @@ let showComputeCenters = true;
|
|||||||
let supercomputerCount = 0;
|
let supercomputerCount = 0;
|
||||||
let gpuClusterCount = 0;
|
let gpuClusterCount = 0;
|
||||||
let unresolvedComputeCenters = [];
|
let unresolvedComputeCenters = [];
|
||||||
|
let previewRingTexture = null;
|
||||||
|
let previewRingGroup = null;
|
||||||
|
let previewRingA = null;
|
||||||
|
let previewRingB = null;
|
||||||
|
let previewRingPulseOffset = 0;
|
||||||
|
|
||||||
const COLLECT_LOCATION_API_BASE = "/api/v1/visualization/compute-centers";
|
const COLLECT_LOCATION_API_BASE = "/api/v1/visualization/compute-centers";
|
||||||
|
|
||||||
|
function getPreviewRingTexture() {
|
||||||
|
if (previewRingTexture) return previewRingTexture;
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 128;
|
||||||
|
canvas.height = 128;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) {
|
||||||
|
previewRingTexture = new THREE.Texture(canvas);
|
||||||
|
return previewRingTexture;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.clearRect(0, 0, 128, 128);
|
||||||
|
context.strokeStyle = "rgba(255,255,255,0.96)";
|
||||||
|
context.lineWidth = 5;
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(64, 64, 43, 0, Math.PI * 2);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
previewRingTexture = new THREE.CanvasTexture(canvas);
|
||||||
|
return previewRingTexture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePreviewRingGroup(earth) {
|
||||||
|
if (!earth) return null;
|
||||||
|
if (!previewRingGroup) {
|
||||||
|
previewRingGroup = new THREE.Group();
|
||||||
|
previewRingGroup.name = "compute-center-location-preview";
|
||||||
|
}
|
||||||
|
if (previewRingGroup.parent !== earth) {
|
||||||
|
previewRingGroup.parent?.remove?.(previewRingGroup);
|
||||||
|
earth.add(previewRingGroup);
|
||||||
|
}
|
||||||
|
return previewRingGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPreviewRingSprite({ color = 0x2dd4bf } = {}) {
|
||||||
|
const sprite = new THREE.Sprite(
|
||||||
|
new THREE.SpriteMaterial({
|
||||||
|
map: getPreviewRingTexture(),
|
||||||
|
color,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
depthWrite: false,
|
||||||
|
depthTest: true,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
sprite.renderOrder = COMPUTE_CENTER_RENDER_ORDER + 0.18;
|
||||||
|
return sprite;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposePreviewRingSprite(sprite) {
|
||||||
|
if (!sprite) return;
|
||||||
|
sprite.parent?.remove?.(sprite);
|
||||||
|
sprite.material?.dispose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachPreviewRings(group, position, color) {
|
||||||
|
disposePreviewRingSprite(previewRingA);
|
||||||
|
disposePreviewRingSprite(previewRingB);
|
||||||
|
previewRingA = createPreviewRingSprite({ color });
|
||||||
|
previewRingB = createPreviewRingSprite({ color });
|
||||||
|
previewRingA.position.copy(position);
|
||||||
|
previewRingB.position.copy(position);
|
||||||
|
group.add(previewRingA);
|
||||||
|
group.add(previewRingB);
|
||||||
|
previewRingPulseOffset = Math.random() * Math.PI * 2;
|
||||||
|
}
|
||||||
|
|
||||||
function buildComputeCenterMarkerData(feature) {
|
function buildComputeCenterMarkerData(feature) {
|
||||||
const props = feature?.properties || {};
|
const props = feature?.properties || {};
|
||||||
const coordinates = feature?.geometry?.coordinates || [];
|
const coordinates = feature?.geometry?.coordinates || [];
|
||||||
@@ -267,9 +345,164 @@ export function clearComputeCenterData(earth) {
|
|||||||
supercomputerCount = 0;
|
supercomputerCount = 0;
|
||||||
gpuClusterCount = 0;
|
gpuClusterCount = 0;
|
||||||
unresolvedComputeCenters = [];
|
unresolvedComputeCenters = [];
|
||||||
|
clearComputeCenterLocationPreview();
|
||||||
computeCenterIconLayer.clearData(earth);
|
computeCenterIconLayer.clearData(earth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearComputeCenterLocationPreview() {
|
||||||
|
disposePreviewRingSprite(previewRingA);
|
||||||
|
disposePreviewRingSprite(previewRingB);
|
||||||
|
previewRingA = null;
|
||||||
|
previewRingB = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showComputeCenterLocationPreview(earth, { latitude, longitude, color = 0x2dd4bf } = {}) {
|
||||||
|
const lat = Number(latitude);
|
||||||
|
const lon = Number(longitude);
|
||||||
|
if (!earth || !Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||||
|
const group = ensurePreviewRingGroup(earth);
|
||||||
|
if (!group) return false;
|
||||||
|
const position = latLonToVector3(
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset + 0.2,
|
||||||
|
);
|
||||||
|
attachPreviewRings(group, position, color);
|
||||||
|
updateComputeCenterLocationPreview();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateComputeCenterLocationPreview() {
|
||||||
|
if (!previewRingA && !previewRingB) return;
|
||||||
|
const now = performance.now();
|
||||||
|
const baseScale = 8.5;
|
||||||
|
const pulseSpeed = 0.00105;
|
||||||
|
const applyRing = (ring, phaseOffset, maxScale) => {
|
||||||
|
if (!ring) return;
|
||||||
|
const phase = (now * pulseSpeed + previewRingPulseOffset + phaseOffset) % 1;
|
||||||
|
const progress = Math.max(0, Math.min(1, phase));
|
||||||
|
const fadeIn = Math.max(0, Math.min(1, (progress - 0.04) / 0.16));
|
||||||
|
const fadeOut = 1 - progress;
|
||||||
|
const visibility = fadeIn * fadeOut;
|
||||||
|
ring.scale.setScalar(baseScale * (1.0 + progress * (maxScale - 1.0)));
|
||||||
|
ring.material.opacity = 0.5 * visibility;
|
||||||
|
ring.visible = true;
|
||||||
|
};
|
||||||
|
applyRing(previewRingA, 0, 1.85);
|
||||||
|
applyRing(previewRingB, 0.45, 2.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeComputeCenterCounts(markerData) {
|
||||||
|
let nextSupercomputerCount = 0;
|
||||||
|
let nextGpuClusterCount = 0;
|
||||||
|
markerData.forEach((item) => {
|
||||||
|
if (item.site_type === "supercomputer") {
|
||||||
|
nextSupercomputerCount += 1;
|
||||||
|
} else {
|
||||||
|
nextGpuClusterCount += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
supercomputerCount = nextSupercomputerCount;
|
||||||
|
gpuClusterCount = nextGpuClusterCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOptimisticComputeCenterMarkerData({ sourceId, candidate, context, saveResult }) {
|
||||||
|
const location = saveResult?.location || {};
|
||||||
|
const existingData = context?.data || {};
|
||||||
|
const latitude = Number(location.latitude ?? candidate?.latitude);
|
||||||
|
const longitude = Number(location.longitude ?? candidate?.longitude);
|
||||||
|
if (!sourceId || !Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const siteType = normalizeSiteType(
|
||||||
|
context?.site_type || context?.siteType || existingData.site_type || location.site_type,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: context?.recordId || context?.id || existingData.id || saveResult?.record_id || sourceId,
|
||||||
|
source_id: sourceId,
|
||||||
|
name:
|
||||||
|
context?.name ||
|
||||||
|
existingData.name ||
|
||||||
|
location.name ||
|
||||||
|
candidate?.matched_location_name ||
|
||||||
|
candidate?.display_name ||
|
||||||
|
"算力中心",
|
||||||
|
source: saveResult?.source || context?.source || existingData.source || location.source || "",
|
||||||
|
site_type: siteType,
|
||||||
|
country: location.country || candidate?.country || context?.country || existingData.country || "",
|
||||||
|
city: location.city || candidate?.city || context?.city || existingData.city || "",
|
||||||
|
region: location.region || candidate?.region || "",
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
displayLatitude: latitude,
|
||||||
|
displayLongitude: longitude,
|
||||||
|
operator: context?.operator || existingData.operator || location.operator || "",
|
||||||
|
vendor: context?.vendor || existingData.vendor || "",
|
||||||
|
capacity_value: context?.capacity_value ?? existingData.capacity_value,
|
||||||
|
capacity_unit: context?.capacity_unit ?? existingData.capacity_unit,
|
||||||
|
rank: context?.rank ?? existingData.rank,
|
||||||
|
gpu_count: context?.gpu_count ?? existingData.gpu_count,
|
||||||
|
gpu_type: context?.gpu_type ?? existingData.gpu_type,
|
||||||
|
cores: context?.cores ?? existingData.cores,
|
||||||
|
power: context?.power ?? existingData.power,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
status: "observed",
|
||||||
|
location_precision: location.precision || candidate?.precision || "city",
|
||||||
|
location_confidence: location.confidence ?? candidate?.confidence ?? null,
|
||||||
|
location_source: location.location_source || candidate?.source || "manual_selection",
|
||||||
|
location_source_note: location.location_source_note || candidate?.source_note || "",
|
||||||
|
location_verified_at: location.verified_at || new Date().toISOString(),
|
||||||
|
matched_location_name:
|
||||||
|
location.matched_location_name ||
|
||||||
|
candidate?.matched_location_name ||
|
||||||
|
candidate?.display_name ||
|
||||||
|
"",
|
||||||
|
needs_confirmation: location.needs_confirmation === true,
|
||||||
|
is_estimated: false,
|
||||||
|
estimated_reason: location.estimated_reason || "",
|
||||||
|
data_type: "compute_center",
|
||||||
|
metadata: context?.metadata || existingData.metadata || {},
|
||||||
|
optimistic_spawn: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function spawnSavedComputeCenterLocation(earth, { sourceId, candidate, context, saveResult } = {}) {
|
||||||
|
clearComputeCenterLocationPreview();
|
||||||
|
const spawned = buildOptimisticComputeCenterMarkerData({
|
||||||
|
sourceId,
|
||||||
|
candidate,
|
||||||
|
context,
|
||||||
|
saveResult,
|
||||||
|
});
|
||||||
|
if (!spawned) return null;
|
||||||
|
|
||||||
|
const currentItems = getComputeCenterMarkers()
|
||||||
|
.map((marker) => ({ ...(marker.userData || {}) }))
|
||||||
|
.filter((item) => item.source_id !== sourceId);
|
||||||
|
currentItems.push(spawned);
|
||||||
|
const nextItems = spreadComputeCenterPositions(currentItems);
|
||||||
|
await computeCenterIconLayer.preloadAssets(nextItems);
|
||||||
|
computeCenterIconLayer.setData(nextItems);
|
||||||
|
computeCenterIconLayer.attach(earth);
|
||||||
|
computeCenterIconLayer.setVisible(showComputeCenters);
|
||||||
|
recomputeComputeCenterCounts(nextItems);
|
||||||
|
unresolvedComputeCenters = unresolvedComputeCenters.filter((item) => {
|
||||||
|
const itemSourceId = item?.source_id || item?.id;
|
||||||
|
return itemSourceId !== sourceId;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
marker: getComputeCenterMarkers().find((marker) => marker.userData?.source_id === sourceId) || null,
|
||||||
|
totalCount: getComputeCenterCount(),
|
||||||
|
supercomputerCount,
|
||||||
|
gpuClusterCount,
|
||||||
|
unresolvedCount: unresolvedComputeCenters.length,
|
||||||
|
unresolved: unresolvedComputeCenters.slice(),
|
||||||
|
summary: getComputeCenterStatusSummary(),
|
||||||
|
optimistic: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getUnresolvedComputeCenters() {
|
export function getUnresolvedComputeCenters() {
|
||||||
return unresolvedComputeCenters.slice();
|
return unresolvedComputeCenters.slice();
|
||||||
}
|
}
|
||||||
@@ -362,7 +595,11 @@ export function getShowComputeCenters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadComputeCenters(_scene, earth) {
|
export async function loadComputeCenters(_scene, earth) {
|
||||||
const response = await fetch(PATHS.computeCentersApi);
|
const separator = PATHS.computeCentersApi.includes("?") ? "&" : "?";
|
||||||
|
const response = await fetch(
|
||||||
|
`${PATHS.computeCentersApi}${separator}_=${Date.now()}`,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Compute centers HTTP ${response.status}`);
|
throw new Error(`Compute centers HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
@@ -411,5 +648,6 @@ export function getComputeCenterPointerIntersections(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
|
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
|
||||||
|
updateComputeCenterLocationPreview();
|
||||||
computeCenterIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
|
computeCenterIconLayer.updateVisualState(lockedObjectType, lockedObject, camera);
|
||||||
}
|
}
|
||||||
|
|||||||
187
frontend/public/earth/js/controls.js
vendored
187
frontend/public/earth/js/controls.js
vendored
@@ -50,8 +50,14 @@ import {
|
|||||||
getShowTrails,
|
getShowTrails,
|
||||||
getSatelliteCount,
|
getSatelliteCount,
|
||||||
getSatelliteDisplayStyle,
|
getSatelliteDisplayStyle,
|
||||||
|
getSatelliteIdleBreathingEnabled,
|
||||||
|
setSatelliteIdleBreathingEnabled as applySatelliteIdleBreathingEnabled,
|
||||||
setSatelliteDisplayStyle as applySatelliteDisplayStyle,
|
setSatelliteDisplayStyle as applySatelliteDisplayStyle,
|
||||||
} from "./satellites.js";
|
} from "./satellites.js";
|
||||||
|
import {
|
||||||
|
getInteractableCompactDotsEnabled,
|
||||||
|
setInteractableCompactDotsEnabled as applyInteractableCompactDotsEnabled,
|
||||||
|
} from "./interactable.js";
|
||||||
import { getShowCables } from "./cables.js";
|
import { getShowCables } from "./cables.js";
|
||||||
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
|
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
|
||||||
import { getShowCountryBoundaries, toggleCountryBoundaries } from "./country-boundaries.js";
|
import { getShowCountryBoundaries, toggleCountryBoundaries } from "./country-boundaries.js";
|
||||||
@@ -135,7 +141,7 @@ const SETTINGS_SHEET_MAX_SCALE_X = 0.22;
|
|||||||
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
|
const SETTINGS_SHEET_MAX_SCALE_Y = 0.18;
|
||||||
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
|
const EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v2";
|
||||||
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
|
const LEGACY_EARTH_SETTINGS_STORAGE_KEY = "planet.earth.settings.v1";
|
||||||
const EARTH_SETTINGS_VERSION = 9;
|
const EARTH_SETTINGS_VERSION = 10;
|
||||||
const GRID_LINES_DEFAULT_VERSION = 3;
|
const GRID_LINES_DEFAULT_VERSION = 3;
|
||||||
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
|
const SATELLITE_DISPLAY_DEFAULT_VERSION = 4;
|
||||||
const MEDIA_PANEL_DEFAULT_VERSION = 5;
|
const MEDIA_PANEL_DEFAULT_VERSION = 5;
|
||||||
@@ -143,8 +149,11 @@ const MOTION_DEBUG_DEFAULT_VERSION = 6;
|
|||||||
const MOTION_PROVIDER_DEFAULT_VERSION = 7;
|
const MOTION_PROVIDER_DEFAULT_VERSION = 7;
|
||||||
const MOTION_DEBUG_SKELETON_ONLY_DEFAULT_VERSION = 8;
|
const MOTION_DEBUG_SKELETON_ONLY_DEFAULT_VERSION = 8;
|
||||||
const MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION = 9;
|
const MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION = 9;
|
||||||
|
const VISUAL_PREFERENCES_DEFAULT_VERSION = 10;
|
||||||
const DEFAULT_EARTH_ZOOM_STEP = 0.01;
|
const DEFAULT_EARTH_ZOOM_STEP = 0.01;
|
||||||
const ZOOM_STATUS_UPDATE_INTERVAL_MS = 90;
|
const ZOOM_STATUS_UPDATE_INTERVAL_MS = 90;
|
||||||
|
const TARGET_SWITCH_ZOOM_IN_PHASE = 0.28;
|
||||||
|
const TARGET_SWITCH_ROTATE_PHASE = 0.5;
|
||||||
let settingsModalTimer = null;
|
let settingsModalTimer = null;
|
||||||
let settingsSheetAnimation = null;
|
let settingsSheetAnimation = null;
|
||||||
let terrainToggleToken = 0;
|
let terrainToggleToken = 0;
|
||||||
@@ -783,6 +792,8 @@ function getCurrentSharedSettingsSnapshot() {
|
|||||||
motionProvider,
|
motionProvider,
|
||||||
motionDebugSkeletonOnly,
|
motionDebugSkeletonOnly,
|
||||||
mediaPanelActiveTab: normalizeMediaPanelActiveTab(getActiveTVTab()),
|
mediaPanelActiveTab: normalizeMediaPanelActiveTab(getActiveTVTab()),
|
||||||
|
satelliteIdleBreathingEnabled: getSatelliteIdleBreathingEnabled(),
|
||||||
|
interactableCompactDotsEnabled: getInteractableCompactDotsEnabled(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,6 +844,10 @@ function cloneEarthSettings(settings) {
|
|||||||
),
|
),
|
||||||
motionDebugSkeletonOnly: Boolean(settings.shared.motionDebugSkeletonOnly),
|
motionDebugSkeletonOnly: Boolean(settings.shared.motionDebugSkeletonOnly),
|
||||||
mediaPanelActiveTab: normalizeMediaPanelActiveTab(settings.shared.mediaPanelActiveTab),
|
mediaPanelActiveTab: normalizeMediaPanelActiveTab(settings.shared.mediaPanelActiveTab),
|
||||||
|
satelliteIdleBreathingEnabled:
|
||||||
|
settings.shared.satelliteIdleBreathingEnabled !== false,
|
||||||
|
interactableCompactDotsEnabled:
|
||||||
|
settings.shared.interactableCompactDotsEnabled !== false,
|
||||||
layerVisibility: { ...(settings.shared.layerVisibility || {}) },
|
layerVisibility: { ...(settings.shared.layerVisibility || {}) },
|
||||||
},
|
},
|
||||||
views: {
|
views: {
|
||||||
@@ -953,6 +968,16 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
|||||||
(rawSettings?.version || 0) >= MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION
|
(rawSettings?.version || 0) >= MEDIA_PANEL_ACTIVE_TAB_DEFAULT_VERSION
|
||||||
? normalizeMediaPanelActiveTab(sharedSettings?.mediaPanelActiveTab)
|
? normalizeMediaPanelActiveTab(sharedSettings?.mediaPanelActiveTab)
|
||||||
: normalizeMediaPanelActiveTab(defaults.shared.mediaPanelActiveTab);
|
: normalizeMediaPanelActiveTab(defaults.shared.mediaPanelActiveTab);
|
||||||
|
const nextSatelliteIdleBreathingEnabled =
|
||||||
|
(rawSettings?.version || 0) >= VISUAL_PREFERENCES_DEFAULT_VERSION &&
|
||||||
|
typeof sharedSettings?.satelliteIdleBreathingEnabled === "boolean"
|
||||||
|
? sharedSettings.satelliteIdleBreathingEnabled
|
||||||
|
: defaults.shared.satelliteIdleBreathingEnabled;
|
||||||
|
const nextInteractableCompactDotsEnabled =
|
||||||
|
(rawSettings?.version || 0) >= VISUAL_PREFERENCES_DEFAULT_VERSION &&
|
||||||
|
typeof sharedSettings?.interactableCompactDotsEnabled === "boolean"
|
||||||
|
? sharedSettings.interactableCompactDotsEnabled
|
||||||
|
: defaults.shared.interactableCompactDotsEnabled;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version: EARTH_SETTINGS_VERSION,
|
version: EARTH_SETTINGS_VERSION,
|
||||||
@@ -972,6 +997,8 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
|||||||
motionProvider: nextMotionProvider,
|
motionProvider: nextMotionProvider,
|
||||||
motionDebugSkeletonOnly: nextMotionDebugSkeletonOnly,
|
motionDebugSkeletonOnly: nextMotionDebugSkeletonOnly,
|
||||||
mediaPanelActiveTab: nextMediaPanelActiveTab,
|
mediaPanelActiveTab: nextMediaPanelActiveTab,
|
||||||
|
satelliteIdleBreathingEnabled: nextSatelliteIdleBreathingEnabled,
|
||||||
|
interactableCompactDotsEnabled: nextInteractableCompactDotsEnabled,
|
||||||
},
|
},
|
||||||
views: {
|
views: {
|
||||||
desktop: {
|
desktop: {
|
||||||
@@ -985,9 +1012,20 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncMotionDebugToggle(nextEnabled = motionDebugEnabled) {
|
function syncMotionDebugToggle(nextEnabled = motionDebugEnabled) {
|
||||||
|
const interactable = rotationMode === ROTATION_MODE.MOTION;
|
||||||
document.querySelectorAll("[data-motion-debug-toggle]").forEach((input) => {
|
document.querySelectorAll("[data-motion-debug-toggle]").forEach((input) => {
|
||||||
if (input instanceof HTMLInputElement) {
|
if (input instanceof HTMLInputElement) {
|
||||||
input.checked = Boolean(nextEnabled);
|
input.checked = Boolean(nextEnabled);
|
||||||
|
input.disabled = !interactable;
|
||||||
|
const label = input.closest("label");
|
||||||
|
label?.classList.toggle("is-disabled", !interactable);
|
||||||
|
if (label instanceof HTMLElement) {
|
||||||
|
if (interactable) {
|
||||||
|
label.removeAttribute("title");
|
||||||
|
} else {
|
||||||
|
label.title = "切换到动捕模式后可开启调试面板";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1010,10 +1048,13 @@ function syncMotionDebugSkeletonOnlyToggle(nextEnabled = motionDebugSkeletonOnly
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dispatchMotionSettingsChange() {
|
function dispatchMotionSettingsChange() {
|
||||||
|
const effectiveDebugEnabled =
|
||||||
|
rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled;
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("earth:motion-debug-mode-change", {
|
new CustomEvent("earth:motion-debug-mode-change", {
|
||||||
detail: {
|
detail: {
|
||||||
enabled: motionDebugEnabled,
|
enabled: effectiveDebugEnabled,
|
||||||
|
preferredEnabled: motionDebugEnabled,
|
||||||
provider: motionProvider,
|
provider: motionProvider,
|
||||||
skeletonOnly: motionDebugSkeletonOnly,
|
skeletonOnly: motionDebugSkeletonOnly,
|
||||||
},
|
},
|
||||||
@@ -1137,6 +1178,24 @@ function syncSatelliteDisplayStyleControls() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncSatelliteIdleBreathingToggle() {
|
||||||
|
const enabled = getSatelliteIdleBreathingEnabled();
|
||||||
|
document.querySelectorAll("[data-satellite-idle-breathing-toggle]").forEach((input) => {
|
||||||
|
if (input instanceof HTMLInputElement) {
|
||||||
|
input.checked = enabled;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncInteractableCompactDotsToggle() {
|
||||||
|
const enabled = getInteractableCompactDotsEnabled();
|
||||||
|
document.querySelectorAll("[data-interactable-compact-dots-toggle]").forEach((input) => {
|
||||||
|
if (input instanceof HTMLInputElement) {
|
||||||
|
input.checked = enabled;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getCruiseModules() {
|
export function getCruiseModules() {
|
||||||
const configuredModules = earthSettingsState?.shared?.cruiseModules;
|
const configuredModules = earthSettingsState?.shared?.cruiseModules;
|
||||||
return normalizeCruiseModules(configuredModules);
|
return normalizeCruiseModules(configuredModules);
|
||||||
@@ -1209,6 +1268,42 @@ export function setSatelliteDisplayStyle(
|
|||||||
return normalizedStyle;
|
return normalizedStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setSatelliteIdleBreathingEnabled(
|
||||||
|
nextEnabled,
|
||||||
|
{ persist = true, suppressStatus = false } = {},
|
||||||
|
) {
|
||||||
|
const enabled = applySatelliteIdleBreathingEnabled(nextEnabled);
|
||||||
|
ensureMutableEarthSettingsState();
|
||||||
|
earthSettingsState.shared.satelliteIdleBreathingEnabled = enabled;
|
||||||
|
syncSatelliteIdleBreathingToggle();
|
||||||
|
|
||||||
|
if (persist) {
|
||||||
|
persistEarthSettings();
|
||||||
|
}
|
||||||
|
if (!suppressStatus) {
|
||||||
|
showStatusMessage(enabled ? "卫星呼吸闪烁已开启" : "卫星呼吸闪烁已关闭", "info");
|
||||||
|
}
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setInteractableCompactDotsEnabled(
|
||||||
|
nextEnabled,
|
||||||
|
{ persist = true, suppressStatus = false } = {},
|
||||||
|
) {
|
||||||
|
const enabled = applyInteractableCompactDotsEnabled(nextEnabled);
|
||||||
|
ensureMutableEarthSettingsState();
|
||||||
|
earthSettingsState.shared.interactableCompactDotsEnabled = enabled;
|
||||||
|
syncInteractableCompactDotsToggle();
|
||||||
|
|
||||||
|
if (persist) {
|
||||||
|
persistEarthSettings();
|
||||||
|
}
|
||||||
|
if (!suppressStatus) {
|
||||||
|
showStatusMessage(enabled ? "低缩放彩色圆点已开启" : "低缩放彩色圆点已关闭", "info");
|
||||||
|
}
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
function syncDefaultEarthZoomUi(nextZoom) {
|
function syncDefaultEarthZoomUi(nextZoom) {
|
||||||
const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
|
const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]");
|
||||||
const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]");
|
const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]");
|
||||||
@@ -1276,6 +1371,14 @@ async function applyEarthSettings(settings, { applyLayers = true } = {}) {
|
|||||||
persist: false,
|
persist: false,
|
||||||
suppressStatus: true,
|
suppressStatus: true,
|
||||||
});
|
});
|
||||||
|
setSatelliteIdleBreathingEnabled(settings.shared.satelliteIdleBreathingEnabled, {
|
||||||
|
persist: false,
|
||||||
|
suppressStatus: true,
|
||||||
|
});
|
||||||
|
setInteractableCompactDotsEnabled(settings.shared.interactableCompactDotsEnabled, {
|
||||||
|
persist: false,
|
||||||
|
suppressStatus: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (typeof settings.shared.dayNightEnabled === "boolean") {
|
if (typeof settings.shared.dayNightEnabled === "boolean") {
|
||||||
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
|
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
|
||||||
@@ -1329,8 +1432,9 @@ export function setMotionDebugEnabled(
|
|||||||
nextEnabled,
|
nextEnabled,
|
||||||
{ persist = true, suppressStatus = false } = {},
|
{ persist = true, suppressStatus = false } = {},
|
||||||
) {
|
) {
|
||||||
const requested = Boolean(nextEnabled);
|
const normalized = Boolean(nextEnabled);
|
||||||
const normalized = requested && rotationMode === ROTATION_MODE.MOTION;
|
const previousEffective =
|
||||||
|
rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled;
|
||||||
const changed = motionDebugEnabled !== normalized;
|
const changed = motionDebugEnabled !== normalized;
|
||||||
motionDebugEnabled = normalized;
|
motionDebugEnabled = normalized;
|
||||||
syncMotionDebugToggle(motionDebugEnabled);
|
syncMotionDebugToggle(motionDebugEnabled);
|
||||||
@@ -1338,19 +1442,21 @@ export function setMotionDebugEnabled(
|
|||||||
ensureMutableEarthSettingsState();
|
ensureMutableEarthSettingsState();
|
||||||
earthSettingsState.shared.motionDebugEnabled = motionDebugEnabled;
|
earthSettingsState.shared.motionDebugEnabled = motionDebugEnabled;
|
||||||
|
|
||||||
if (changed) {
|
const nextEffective =
|
||||||
|
rotationMode === ROTATION_MODE.MOTION && autoRotate && motionDebugEnabled;
|
||||||
|
if (changed || previousEffective !== nextEffective) {
|
||||||
dispatchMotionSettingsChange();
|
dispatchMotionSettingsChange();
|
||||||
}
|
}
|
||||||
if (persist) {
|
if (persist) {
|
||||||
persistEarthSettings();
|
persistEarthSettings();
|
||||||
}
|
}
|
||||||
if (!suppressStatus && changed) {
|
if (!suppressStatus && changed) {
|
||||||
showStatusMessage(
|
const message = motionDebugEnabled
|
||||||
motionDebugEnabled ? "动捕调试模式已开启" : "动捕调试模式已关闭",
|
? rotationMode === ROTATION_MODE.MOTION
|
||||||
"info",
|
? "动捕调试模式已开启"
|
||||||
);
|
: "动捕调试模式将在下次进入动捕时开启"
|
||||||
} else if (!suppressStatus && requested && rotationMode !== ROTATION_MODE.MOTION) {
|
: "动捕调试模式已关闭";
|
||||||
showStatusMessage("请先切换到动捕模式再打开调试面板", "info");
|
showStatusMessage(message, "info");
|
||||||
}
|
}
|
||||||
return motionDebugEnabled;
|
return motionDebugEnabled;
|
||||||
}
|
}
|
||||||
@@ -2585,6 +2691,20 @@ function setupSettingsControls() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-satellite-idle-breathing-toggle]").forEach((toggle) => {
|
||||||
|
if (!(toggle instanceof HTMLInputElement)) return;
|
||||||
|
bindListener(toggle, "change", () => {
|
||||||
|
setSatelliteIdleBreathingEnabled(toggle.checked);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-interactable-compact-dots-toggle]").forEach((toggle) => {
|
||||||
|
if (!(toggle instanceof HTMLInputElement)) return;
|
||||||
|
bindListener(toggle, "change", () => {
|
||||||
|
setInteractableCompactDotsEnabled(toggle.checked);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => {
|
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => {
|
||||||
if (!(dayNightToggle instanceof HTMLInputElement)) return;
|
if (!(dayNightToggle instanceof HTMLInputElement)) return;
|
||||||
bindListener(dayNightToggle, "change", () => {
|
bindListener(dayNightToggle, "change", () => {
|
||||||
@@ -3954,6 +4074,7 @@ function updateRotateUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncRotationModeButtons();
|
syncRotationModeButtons();
|
||||||
|
syncMotionDebugToggle(motionDebugEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAutoRotate(value) {
|
export function setAutoRotate(value) {
|
||||||
@@ -3990,9 +4111,6 @@ export function setRotationMode(nextMode, { persist = true, suppressStatus = fal
|
|||||||
autoRotate = true;
|
autoRotate = true;
|
||||||
}
|
}
|
||||||
rotationMode = normalizedMode;
|
rotationMode = normalizedMode;
|
||||||
if (normalizedMode !== ROTATION_MODE.MOTION && motionDebugEnabled) {
|
|
||||||
setMotionDebugEnabled(false, { persist, suppressStatus: true });
|
|
||||||
}
|
|
||||||
updateRotateUI();
|
updateRotateUI();
|
||||||
dispatchRotationModeChange();
|
dispatchRotationModeChange();
|
||||||
if (persist) {
|
if (persist) {
|
||||||
@@ -4013,6 +4131,7 @@ export function focusEarthView(camera, options = {}) {
|
|||||||
zoom = getDefaultEarthZoomLevel(),
|
zoom = getDefaultEarthZoomLevel(),
|
||||||
duration = 800,
|
duration = 800,
|
||||||
suppressStatus = true,
|
suppressStatus = true,
|
||||||
|
zoomTransitionMode = "direct",
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -4020,6 +4139,13 @@ export function focusEarthView(camera, options = {}) {
|
|||||||
const startRotX = earthObj.rotation.x;
|
const startRotX = earthObj.rotation.x;
|
||||||
const startRotY = earthObj.rotation.y;
|
const startRotY = earthObj.rotation.y;
|
||||||
const startZoom = zoomLevel;
|
const startZoom = zoomLevel;
|
||||||
|
const defaultZoom = getDefaultEarthZoomLevel();
|
||||||
|
const shouldRestoreZoomViaDefault =
|
||||||
|
zoomTransitionMode === "restore-current-via-default" &&
|
||||||
|
Math.abs(startZoom - defaultZoom) > 0.005;
|
||||||
|
const rotateStartProgress = TARGET_SWITCH_ZOOM_IN_PHASE;
|
||||||
|
const rotateEndProgress =
|
||||||
|
TARGET_SWITCH_ZOOM_IN_PHASE + TARGET_SWITCH_ROTATE_PHASE;
|
||||||
|
|
||||||
animateValue(
|
animateValue(
|
||||||
0,
|
0,
|
||||||
@@ -4027,14 +4153,39 @@ export function focusEarthView(camera, options = {}) {
|
|||||||
duration,
|
duration,
|
||||||
(progress) => {
|
(progress) => {
|
||||||
const ease = 1 - Math.pow(1 - progress, 3);
|
const ease = 1 - Math.pow(1 - progress, 3);
|
||||||
earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * ease;
|
if (shouldRestoreZoomViaDefault) {
|
||||||
earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * ease;
|
const rotateProgress = THREE.MathUtils.clamp(
|
||||||
zoomLevel = startZoom + (zoom - startZoom) * ease;
|
(progress - rotateStartProgress) / TARGET_SWITCH_ROTATE_PHASE,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const rotateEase = 1 - Math.pow(1 - rotateProgress, 3);
|
||||||
|
earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * rotateEase;
|
||||||
|
earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * rotateEase;
|
||||||
|
|
||||||
|
if (progress < rotateStartProgress) {
|
||||||
|
const zoomProgress = progress / rotateStartProgress;
|
||||||
|
const zoomEase = 1 - Math.pow(1 - zoomProgress, 3);
|
||||||
|
zoomLevel = startZoom + (defaultZoom - startZoom) * zoomEase;
|
||||||
|
} else if (progress <= rotateEndProgress) {
|
||||||
|
zoomLevel = defaultZoom;
|
||||||
|
} else {
|
||||||
|
const zoomProgress = (progress - rotateEndProgress) / (1 - rotateEndProgress);
|
||||||
|
const zoomEase = 1 - Math.pow(1 - zoomProgress, 3);
|
||||||
|
zoomLevel = defaultZoom + (startZoom - defaultZoom) * zoomEase;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * ease;
|
||||||
|
earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * ease;
|
||||||
|
zoomLevel = startZoom + (zoom - startZoom) * ease;
|
||||||
|
}
|
||||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||||
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
zoomLevel = zoom;
|
zoomLevel = shouldRestoreZoomViaDefault ? startZoom : zoom;
|
||||||
|
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||||
|
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||||||
if (!suppressStatus) {
|
if (!suppressStatus) {
|
||||||
showStatusMessage("视角已重置", "info");
|
showStatusMessage("视角已重置", "info");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ let pendingMobileDetailState = null;
|
|||||||
let mobileDetailsListenerBound = false;
|
let mobileDetailsListenerBound = false;
|
||||||
let renderedMobileDetailKey = null;
|
let renderedMobileDetailKey = null;
|
||||||
const locationCollectStateCache = new Map();
|
const locationCollectStateCache = new Map();
|
||||||
|
const locationCollectContextCache = new Map();
|
||||||
|
// Latest candidate list per cache-key. Populated whenever state.candidates is
|
||||||
|
// updated, and read by the click handler via `data-candidate-index` so we
|
||||||
|
// never have to round-trip a candidate object through an HTML attribute.
|
||||||
|
const locationCollectCandidatesByKey = new Map();
|
||||||
const IDENTIFIER_FIELD_KEYS = new Set([
|
const IDENTIFIER_FIELD_KEYS = new Set([
|
||||||
'mmsi',
|
'mmsi',
|
||||||
'mmsi_display',
|
'mmsi_display',
|
||||||
@@ -37,6 +42,12 @@ function setLocationCollectState(contextOrKey, patch = {}) {
|
|||||||
? contextOrKey
|
? contextOrKey
|
||||||
: getLocationCollectCacheKey(contextOrKey);
|
: getLocationCollectCacheKey(contextOrKey);
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
|
if (typeof contextOrKey !== 'string') {
|
||||||
|
locationCollectContextCache.set(key, contextOrKey);
|
||||||
|
}
|
||||||
|
if (Array.isArray(patch.candidates)) {
|
||||||
|
locationCollectCandidatesByKey.set(key, patch.candidates);
|
||||||
|
}
|
||||||
const previous = locationCollectStateCache.get(key) || {};
|
const previous = locationCollectStateCache.get(key) || {};
|
||||||
const next = {
|
const next = {
|
||||||
...previous,
|
...previous,
|
||||||
@@ -54,14 +65,28 @@ function clearLocationCollectState(contextOrKey) {
|
|||||||
: getLocationCollectCacheKey(contextOrKey);
|
: getLocationCollectCacheKey(contextOrKey);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
locationCollectStateCache.delete(key);
|
locationCollectStateCache.delete(key);
|
||||||
|
locationCollectContextCache.delete(key);
|
||||||
|
locationCollectCandidatesByKey.delete(key);
|
||||||
updateLocationCollectDomFromState(key);
|
updateLocationCollectDomFromState(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCandidateForButton(button) {
|
||||||
|
if (!(button instanceof HTMLElement)) return null;
|
||||||
|
const root = button.closest('[data-collect-cache-key]');
|
||||||
|
if (!(root instanceof HTMLElement)) return null;
|
||||||
|
const key = root.dataset.collectCacheKey || '';
|
||||||
|
const list = locationCollectCandidatesByKey.get(key) || [];
|
||||||
|
const index = Number(button.dataset.candidateIndex);
|
||||||
|
if (!Number.isFinite(index) || index < 0 || index >= list.length) return null;
|
||||||
|
return list[index];
|
||||||
|
}
|
||||||
|
|
||||||
function updateLocationCollectDomFromState(key) {
|
function updateLocationCollectDomFromState(key) {
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
const state = getLocationCollectState(key);
|
const state = getLocationCollectState(key);
|
||||||
document.querySelectorAll(`[data-collect-cache-key="${escapeCssIdentifier(key)}"]`).forEach((root) => {
|
document.querySelectorAll(`[data-collect-cache-key="${escapeCssIdentifier(key)}"]`).forEach((root) => {
|
||||||
hydrateLocationCollectRoot(root, state);
|
hydrateLocationCollectRoot(root, state);
|
||||||
|
ensureCandidateActionBindings(root, locationCollectContextCache.get(key));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +401,7 @@ function renderCachedCollectCandidates(state) {
|
|||||||
if (!candidates.length) return '';
|
if (!candidates.length) return '';
|
||||||
return candidates
|
return candidates
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0))
|
.map((candidate, index) => renderCollectCandidateRow(candidate, index === 0, index))
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +415,128 @@ function hydrateLocationCollectRoot(root, state) {
|
|||||||
if (button instanceof HTMLButtonElement) button.disabled = state?.loading === true;
|
if (button instanceof HTMLButtonElement) button.disabled = state?.loading === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rememberLocationCollectContext(context) {
|
||||||
|
const key = getLocationCollectCacheKey(context);
|
||||||
|
if (!key) return '';
|
||||||
|
locationCollectContextCache.set(key, context);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocationCollectContextForRoot(root, fallbackContext) {
|
||||||
|
const key = root?.dataset?.collectCacheKey || getLocationCollectCacheKey(fallbackContext);
|
||||||
|
if (key && locationCollectContextCache.has(key)) {
|
||||||
|
return locationCollectContextCache.get(key);
|
||||||
|
}
|
||||||
|
if (fallbackContext) {
|
||||||
|
rememberLocationCollectContext(fallbackContext);
|
||||||
|
return fallbackContext;
|
||||||
|
}
|
||||||
|
const collectButton = root?.querySelector?.('[data-unresolved-collect]');
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(collectButton?.dataset?.contextJson || '{}');
|
||||||
|
if (!parsed?.sourceId) return null;
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
entityType: 'compute_center',
|
||||||
|
entityId: parsed.sourceId,
|
||||||
|
isUnresolved: true,
|
||||||
|
save: async (candidate) => {
|
||||||
|
const mod = await import('./compute-centers.js');
|
||||||
|
return mod.saveComputeCenterLocation(parsed.sourceId, candidate, parsed);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single delegated click handler attached once per cache-key root.
|
||||||
|
// Lookup model: button -> closest('[data-collect-cache-key]') -> map by key.
|
||||||
|
// Candidates live in `locationCollectCandidatesByKey`, indexed by
|
||||||
|
// `data-candidate-index` on the button -- no JSON round-tripped through HTML.
|
||||||
|
function ensureCandidateActionBindings(rootOrChild, context) {
|
||||||
|
const root = rootOrChild instanceof Element
|
||||||
|
? (rootOrChild.closest?.('[data-collect-cache-key]') || rootOrChild)
|
||||||
|
: null;
|
||||||
|
if (!(root instanceof HTMLElement)) return;
|
||||||
|
const key = rememberLocationCollectContext(context) || root.dataset.collectCacheKey || '';
|
||||||
|
if (key) root.dataset.collectCacheKey = key;
|
||||||
|
if (root.dataset.candidateActionsBound === 'true') return;
|
||||||
|
root.dataset.candidateActionsBound = 'true';
|
||||||
|
|
||||||
|
root.addEventListener('click', async (event) => {
|
||||||
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
|
if (!target) return;
|
||||||
|
const previewButton = target.closest('[data-preview-candidate]');
|
||||||
|
const saveButton = target.closest('[data-save-candidate]');
|
||||||
|
const button = previewButton || saveButton;
|
||||||
|
if (!(button instanceof HTMLElement) || !root.contains(button)) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const actionContext = getLocationCollectContextForRoot(root, context);
|
||||||
|
if (!actionContext) return;
|
||||||
|
|
||||||
|
const candidate = getCandidateForButton(button);
|
||||||
|
if (!candidate) return;
|
||||||
|
|
||||||
|
if (previewButton) {
|
||||||
|
const lat = Number(candidate.latitude);
|
||||||
|
const lon = Number(candidate.longitude);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('earth:preview-location-candidate', {
|
||||||
|
detail: {
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
entityType: actionContext.entityType,
|
||||||
|
entityId: actionContext.entityId,
|
||||||
|
candidate,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof actionContext.save !== 'function') return;
|
||||||
|
const statusEl = root.querySelector('[data-collect-status], [data-unresolved-status]');
|
||||||
|
button.disabled = true;
|
||||||
|
if (statusEl) statusEl.textContent = '正在保存所选坐标...';
|
||||||
|
try {
|
||||||
|
const saveResult = await actionContext.save(candidate);
|
||||||
|
setLocationCollectState(actionContext, {
|
||||||
|
loading: false,
|
||||||
|
statusText: '坐标已保存',
|
||||||
|
candidates: [],
|
||||||
|
});
|
||||||
|
if (statusEl) statusEl.textContent = '坐标已保存';
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('earth:compute-center-location-saved', {
|
||||||
|
detail: {
|
||||||
|
entityType: actionContext.entityType,
|
||||||
|
entityId: actionContext.entityId,
|
||||||
|
candidate,
|
||||||
|
context: actionContext,
|
||||||
|
result: saveResult,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (actionContext.entityType === 'compute_center' && actionContext.isUnresolved === true) {
|
||||||
|
const itemRoot = root.closest('[data-unresolved-item]');
|
||||||
|
if (itemRoot) {
|
||||||
|
removeResolvedUnresolvedItem(
|
||||||
|
itemRoot.closest('#info-card-content') || document,
|
||||||
|
itemRoot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('save compute-center location failed', error);
|
||||||
|
if (statusEl) statusEl.textContent = `保存失败:${error?.message || error}`;
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function formatLocationCollectFailure(result) {
|
function formatLocationCollectFailure(result) {
|
||||||
const regularReason = result?.failure_reason || '常规来源没有可用坐标候选';
|
const regularReason = result?.failure_reason || '常规来源没有可用坐标候选';
|
||||||
const llmReason = result?.llm_failure_reason;
|
const llmReason = result?.llm_failure_reason;
|
||||||
@@ -408,14 +555,14 @@ function bindLocationCollectControls(content, context) {
|
|||||||
const collectRoot = content.querySelector('[data-collect-entity-id]');
|
const collectRoot = content.querySelector('[data-collect-entity-id]');
|
||||||
if (!collectRoot) return;
|
if (!collectRoot) return;
|
||||||
const button = collectRoot.querySelector('[data-collect-action="run"]');
|
const button = collectRoot.querySelector('[data-collect-action="run"]');
|
||||||
const statusEl = collectRoot.querySelector('[data-collect-status]');
|
|
||||||
const candidatesEl = collectRoot.querySelector('[data-collect-candidates]');
|
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
|
// Bind the delegated preview/save handler once -- works both before and
|
||||||
|
// after the user has run "采集", because innerHTML replacement of the
|
||||||
|
// candidates container does not detach handlers higher up the tree.
|
||||||
|
ensureCandidateActionBindings(collectRoot, context);
|
||||||
const cachedState = getLocationCollectState(context);
|
const cachedState = getLocationCollectState(context);
|
||||||
if (cachedState) {
|
if (cachedState) {
|
||||||
hydrateLocationCollectRoot(collectRoot, cachedState);
|
hydrateLocationCollectRoot(collectRoot, cachedState);
|
||||||
bindCandidatePreviewButtons(candidatesEl, context);
|
|
||||||
bindCandidateSaveButtons(candidatesEl, context, statusEl);
|
|
||||||
}
|
}
|
||||||
button.addEventListener('click', async (event) => {
|
button.addEventListener('click', async (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -443,8 +590,6 @@ function bindLocationCollectControls(content, context) {
|
|||||||
candidates,
|
candidates,
|
||||||
result,
|
result,
|
||||||
});
|
});
|
||||||
bindCandidatePreviewButtons(candidatesEl, context);
|
|
||||||
bindCandidateSaveButtons(candidatesEl, context, statusEl);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('collect-location failed', error);
|
console.error('collect-location failed', error);
|
||||||
setLocationCollectState(context, {
|
setLocationCollectState(context, {
|
||||||
@@ -455,13 +600,11 @@ function bindLocationCollectControls(content, context) {
|
|||||||
} finally {
|
} finally {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
updateLocationCollectDomFromState(getLocationCollectCacheKey(context));
|
updateLocationCollectDomFromState(getLocationCollectCacheKey(context));
|
||||||
bindCandidatePreviewButtons(candidatesEl, context);
|
|
||||||
bindCandidateSaveButtons(candidatesEl, context, statusEl);
|
|
||||||
}
|
}
|
||||||
}, { once: false });
|
}, { once: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCollectCandidateRow(candidate, isBest) {
|
function renderCollectCandidateRow(candidate, isBest, index) {
|
||||||
const precisionLabel = {
|
const precisionLabel = {
|
||||||
precise: '精确',
|
precise: '精确',
|
||||||
site: '站点',
|
site: '站点',
|
||||||
@@ -470,24 +613,23 @@ function renderCollectCandidateRow(candidate, isBest) {
|
|||||||
const confidence = Number.isFinite(Number(candidate.confidence))
|
const confidence = Number.isFinite(Number(candidate.confidence))
|
||||||
? `${Math.round(Number(candidate.confidence) * 100)}%`
|
? `${Math.round(Number(candidate.confidence) * 100)}%`
|
||||||
: '-';
|
: '-';
|
||||||
const candidateJson = JSON.stringify(candidate).replace(/"/g, '"');
|
const safeIndex = Number.isFinite(Number(index)) ? Number(index) : 0;
|
||||||
|
const name = escapeInfoCardHtml(candidate.matched_location_name || candidate.display_name || '候选');
|
||||||
|
const sourceLabel = escapeInfoCardHtml(candidate.source || '');
|
||||||
return `
|
return `
|
||||||
<div class="info-card-compute-candidate ${isBest ? 'is-best' : ''}">
|
<div class="info-card-compute-candidate ${isBest ? 'is-best' : ''}">
|
||||||
<div class="info-card-compute-candidate-line">
|
<div class="info-card-compute-candidate-line">
|
||||||
<span class="info-card-compute-candidate-name">${candidate.matched_location_name || candidate.display_name || '候选'}</span>
|
<span class="info-card-compute-candidate-name">${name}</span>
|
||||||
<span class="info-card-compute-candidate-precision">${precisionLabel}</span>
|
<span class="info-card-compute-candidate-precision">${escapeInfoCardHtml(precisionLabel)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card-compute-candidate-line">
|
<div class="info-card-compute-candidate-line">
|
||||||
<span class="info-card-compute-candidate-source">${candidate.source}</span>
|
<span class="info-card-compute-candidate-source">${sourceLabel}</span>
|
||||||
<span class="info-card-compute-candidate-confidence">置信 ${confidence}</span>
|
<span class="info-card-compute-candidate-confidence">置信 ${escapeInfoCardHtml(confidence)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card-compute-candidate-line">
|
<div class="info-card-compute-candidate-line">
|
||||||
<span class="info-card-compute-candidate-coords">${Number(candidate.latitude).toFixed(4)}, ${Number(candidate.longitude).toFixed(4)}</span>
|
<span class="info-card-compute-candidate-coords">${Number(candidate.latitude).toFixed(4)}, ${Number(candidate.longitude).toFixed(4)}</span>
|
||||||
<button type="button" class="info-card-compute-candidate-preview" data-preview-candidate
|
<button type="button" class="info-card-compute-candidate-preview" data-preview-candidate data-candidate-index="${safeIndex}">预览</button>
|
||||||
data-lat="${candidate.latitude}" data-lon="${candidate.longitude}"
|
<button type="button" class="info-card-compute-candidate-preview" data-save-candidate data-candidate-index="${safeIndex}">保存</button>
|
||||||
data-candidate-json="${candidateJson}">预览</button>
|
|
||||||
<button type="button" class="info-card-compute-candidate-preview" data-save-candidate
|
|
||||||
data-candidate-json="${candidateJson}">保存</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -503,6 +645,7 @@ function getUnresolvedComputeCenterContext(item) {
|
|||||||
sourceId: item?.source_id || item?.id || '',
|
sourceId: item?.source_id || item?.id || '',
|
||||||
recordId: item?.id || item?.record_id || '',
|
recordId: item?.id || item?.record_id || '',
|
||||||
name: item?.name || item?.title || '未命名算力中心',
|
name: item?.name || item?.title || '未命名算力中心',
|
||||||
|
site_type: item?.site_type || metadata.site_type || '',
|
||||||
operator: item?.operator || item?.vendor || metadata.operator || '',
|
operator: item?.operator || item?.vendor || metadata.operator || '',
|
||||||
site: item?.site || metadata.site || metadata.organization || '',
|
site: item?.site || metadata.site || metadata.organization || '',
|
||||||
city: item?.city || metadata.city || '',
|
city: item?.city || metadata.city || '',
|
||||||
@@ -646,77 +789,14 @@ function getBestLocationCandidate(candidates) {
|
|||||||
})[0] || null;
|
})[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindCandidatePreviewButtons(container, context) {
|
|
||||||
container.querySelectorAll('[data-preview-candidate]').forEach((el) => {
|
|
||||||
el.addEventListener('click', (clickEvt) => {
|
|
||||||
clickEvt.stopPropagation();
|
|
||||||
const lat = Number(el.dataset.lat);
|
|
||||||
const lon = Number(el.dataset.lon);
|
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('earth:preview-location-candidate', {
|
|
||||||
detail: {
|
|
||||||
latitude: lat,
|
|
||||||
longitude: lon,
|
|
||||||
entityType: context.entityType,
|
|
||||||
entityId: context.entityId,
|
|
||||||
candidate: JSON.parse(el.dataset.candidateJson || '{}'),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindCandidateSaveButtons(container, context, statusEl) {
|
|
||||||
container.querySelectorAll('[data-save-candidate]').forEach((el) => {
|
|
||||||
el.addEventListener('click', async (clickEvt) => {
|
|
||||||
clickEvt.stopPropagation();
|
|
||||||
if (typeof context.save !== 'function') return;
|
|
||||||
const candidate = JSON.parse(el.dataset.candidateJson || '{}');
|
|
||||||
el.disabled = true;
|
|
||||||
if (statusEl) statusEl.textContent = '正在保存所选坐标...';
|
|
||||||
try {
|
|
||||||
await context.save(candidate);
|
|
||||||
setLocationCollectState(context, {
|
|
||||||
loading: false,
|
|
||||||
statusText: '坐标已保存,正在后台刷新图层...',
|
|
||||||
candidates: [],
|
|
||||||
});
|
|
||||||
if (statusEl) statusEl.textContent = '坐标已保存,正在后台刷新图层...';
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('earth:compute-center-location-saved', {
|
|
||||||
detail: {
|
|
||||||
entityType: context.entityType,
|
|
||||||
entityId: context.entityId,
|
|
||||||
candidate,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (context.entityType === 'compute_center' && context.isUnresolved === true) {
|
|
||||||
const itemRoot = container.closest('[data-unresolved-item]');
|
|
||||||
if (itemRoot) {
|
|
||||||
removeResolvedUnresolvedItem(document, itemRoot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('save compute-center location failed', error);
|
|
||||||
if (statusEl) statusEl.textContent = `保存失败:${error?.message || error}`;
|
|
||||||
} finally {
|
|
||||||
el.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindComputeCenterUnresolvedControls(content) {
|
function bindComputeCenterUnresolvedControls(content) {
|
||||||
content.querySelectorAll('[data-unresolved-item]').forEach((itemRoot) => {
|
content.querySelectorAll('[data-unresolved-item]').forEach((itemRoot) => {
|
||||||
const collectButton = itemRoot.querySelector('[data-unresolved-collect]');
|
const collectButton = itemRoot.querySelector('[data-unresolved-collect]');
|
||||||
const candidatesEl = itemRoot.querySelector('[data-unresolved-candidates]');
|
const candidatesEl = itemRoot.querySelector('[data-unresolved-candidates]');
|
||||||
const statusEl = itemRoot.querySelector('[data-unresolved-status]');
|
|
||||||
const context = JSON.parse(collectButton?.dataset.contextJson || '{}');
|
const context = JSON.parse(collectButton?.dataset.contextJson || '{}');
|
||||||
if (!context.sourceId || !candidatesEl) return;
|
if (!context.sourceId || !candidatesEl) return;
|
||||||
const actionContext = {
|
const actionContext = {
|
||||||
|
...context,
|
||||||
entityType: 'compute_center',
|
entityType: 'compute_center',
|
||||||
entityId: context.sourceId,
|
entityId: context.sourceId,
|
||||||
isUnresolved: true,
|
isUnresolved: true,
|
||||||
@@ -725,8 +805,7 @@ function bindComputeCenterUnresolvedControls(content) {
|
|||||||
return mod.saveComputeCenterLocation(context.sourceId, candidate, context);
|
return mod.saveComputeCenterLocation(context.sourceId, candidate, context);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
bindCandidatePreviewButtons(candidatesEl, actionContext);
|
ensureCandidateActionBindings(itemRoot, actionContext);
|
||||||
bindCandidateSaveButtons(candidatesEl, actionContext, statusEl);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
content.querySelectorAll('[data-unresolved-collect]').forEach((button) => {
|
content.querySelectorAll('[data-unresolved-collect]').forEach((button) => {
|
||||||
@@ -762,13 +841,13 @@ function bindComputeCenterUnresolvedControls(content) {
|
|||||||
result,
|
result,
|
||||||
});
|
});
|
||||||
const actionContext = {
|
const actionContext = {
|
||||||
|
...context,
|
||||||
entityType: 'compute_center',
|
entityType: 'compute_center',
|
||||||
entityId: context.sourceId,
|
entityId: context.sourceId,
|
||||||
isUnresolved: true,
|
isUnresolved: true,
|
||||||
save: (candidate) => mod.saveComputeCenterLocation(context.sourceId, candidate, context),
|
save: (candidate) => mod.saveComputeCenterLocation(context.sourceId, candidate, context),
|
||||||
};
|
};
|
||||||
bindCandidatePreviewButtons(candidatesEl, actionContext);
|
ensureCandidateActionBindings(itemRoot, actionContext);
|
||||||
bindCandidateSaveButtons(candidatesEl, actionContext, statusEl);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('collect unresolved compute-center location failed', error);
|
console.error('collect unresolved compute-center location failed', error);
|
||||||
setLocationCollectState(context, {
|
setLocationCollectState(context, {
|
||||||
@@ -779,17 +858,6 @@ function bindComputeCenterUnresolvedControls(content) {
|
|||||||
} finally {
|
} finally {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
updateLocationCollectDomFromState(getLocationCollectCacheKey(context));
|
updateLocationCollectDomFromState(getLocationCollectCacheKey(context));
|
||||||
const actionContext = {
|
|
||||||
entityType: 'compute_center',
|
|
||||||
entityId: context.sourceId,
|
|
||||||
isUnresolved: true,
|
|
||||||
save: async (candidate) => {
|
|
||||||
const mod = await import('./compute-centers.js');
|
|
||||||
return mod.saveComputeCenterLocation(context.sourceId, candidate, context);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
bindCandidatePreviewButtons(candidatesEl, actionContext);
|
|
||||||
bindCandidateSaveButtons(candidatesEl, actionContext, statusEl);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const DEFAULT_AVOIDANCE_PRECISION = 4;
|
|||||||
const DEFAULT_AVOIDANCE_RADIUS = 1.1;
|
const DEFAULT_AVOIDANCE_RADIUS = 1.1;
|
||||||
const DEFAULT_AVOIDANCE_STEP = 0.35;
|
const DEFAULT_AVOIDANCE_STEP = 0.35;
|
||||||
const AVOIDANCE_RING_SLOT_COUNT = 8;
|
const AVOIDANCE_RING_SLOT_COUNT = 8;
|
||||||
|
const COMPACT_DOT_ZOOM_THRESHOLD = 1.5;
|
||||||
|
const COMPACT_DOT_POINT_SIZE = 12;
|
||||||
|
const COMPACT_DOT_RADIUS_RATIO = 0.26;
|
||||||
|
let compactDotsEnabled = true;
|
||||||
|
|
||||||
// Named avoidance profiles. Layers that should mutex with each other (e.g. fan
|
// Named avoidance profiles. Layers that should mutex with each other (e.g. fan
|
||||||
// out when sharing the same city center) must reference the SAME profile —
|
// out when sharing the same city center) must reference the SAME profile —
|
||||||
@@ -78,6 +82,18 @@ function createCanvas(width, height) {
|
|||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getInteractableCompactDotsEnabled() {
|
||||||
|
return compactDotsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setInteractableCompactDotsEnabled(enabled) {
|
||||||
|
compactDotsEnabled = Boolean(enabled);
|
||||||
|
interactableLayerControllers.forEach((controller) => {
|
||||||
|
controller.refreshVisuals?.();
|
||||||
|
});
|
||||||
|
return compactDotsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
function getAvoidanceKey(item, position, basePosition, config) {
|
function getAvoidanceKey(item, position, basePosition, config) {
|
||||||
if (typeof config?.getKey === "function") {
|
if (typeof config?.getKey === "function") {
|
||||||
const key = config.getKey(item, position, basePosition);
|
const key = config.getKey(item, position, basePosition);
|
||||||
@@ -471,7 +487,39 @@ export function createInteractableLayer(options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOverlayTexture(marker, state) {
|
function createCompactDotTexture(marker, state = "normal") {
|
||||||
|
const kind = marker?.userData?.icon_kind || "default";
|
||||||
|
const color = state === "normal" ? "#ffffff" : getMarkerColor(marker);
|
||||||
|
const textureKey = `compact-dot:${state}:${kind}:${color}`;
|
||||||
|
if (textureCache.has(textureKey)) return textureCache.get(textureKey);
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = atlasCellSize;
|
||||||
|
canvas.height = atlasCellSize;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
const center = atlasCellSize / 2;
|
||||||
|
const radius = atlasCellSize * COMPACT_DOT_RADIUS_RATIO;
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.fillStyle = color;
|
||||||
|
context.shadowColor = color;
|
||||||
|
context.shadowBlur = atlasCellSize * 0.08;
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(center, center, radius, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.generateMipmaps = false;
|
||||||
|
texture.minFilter = THREE.LinearFilter;
|
||||||
|
texture.magFilter = THREE.LinearFilter;
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
textureCache.set(textureKey, texture);
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOverlayTexture(marker, state, compactDotMode = false) {
|
||||||
|
if (compactDotMode) {
|
||||||
|
return createCompactDotTexture(marker, state);
|
||||||
|
}
|
||||||
const kind = marker?.userData?.icon_kind || "default";
|
const kind = marker?.userData?.icon_kind || "default";
|
||||||
const rotationBin = getRotationBin(marker);
|
const rotationBin = getRotationBin(marker);
|
||||||
const color = getMarkerColor(marker);
|
const color = getMarkerColor(marker);
|
||||||
@@ -503,6 +551,33 @@ export function createInteractableLayer(options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCameraZoom(camera) {
|
||||||
|
if (!camera?.position?.z) return CONFIG.defaultViewZoom;
|
||||||
|
return CONFIG.defaultCameraZ / camera.position.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseCompactDots(camera) {
|
||||||
|
return compactDotsEnabled && getCameraZoom(camera) < COMPACT_DOT_ZOOM_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePointColors(points, compactDotMode) {
|
||||||
|
const bucketMarkers = points.userData?.markers || [];
|
||||||
|
const colorAttribute = points.geometry?.getAttribute("color");
|
||||||
|
if (!colorAttribute?.array) return;
|
||||||
|
|
||||||
|
bucketMarkers.forEach((marker, index) => {
|
||||||
|
const pointColor =
|
||||||
|
compactDotMode || icon.colorable !== false
|
||||||
|
? getMarkerColor(marker)
|
||||||
|
: "#ffffff";
|
||||||
|
const [r, g, b] = colorToRgbArray(pointColor);
|
||||||
|
colorAttribute.array[index * 3] = r;
|
||||||
|
colorAttribute.array[index * 3 + 1] = g;
|
||||||
|
colorAttribute.array[index * 3 + 2] = b;
|
||||||
|
});
|
||||||
|
colorAttribute.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
function buildPoints() {
|
function buildPoints() {
|
||||||
refreshViewportSize();
|
refreshViewportSize();
|
||||||
pointsGroup = new THREE.Group();
|
pointsGroup = new THREE.Group();
|
||||||
@@ -609,21 +684,23 @@ export function createInteractableLayer(options = {}) {
|
|||||||
return overlay;
|
return overlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateOverlay(overlay, marker, state, nextOpacity, sizeMultiplier = 1) {
|
function updateOverlay(overlay, marker, state, nextOpacity, sizeMultiplier = 1, compactDotMode = false) {
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
if (!marker) {
|
if (!marker) {
|
||||||
overlay.visible = false;
|
overlay.visible = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const texture = createOverlayTexture(marker, state);
|
const texture = createOverlayTexture(marker, state, compactDotMode);
|
||||||
if (overlay.material.map !== texture) {
|
if (overlay.material.map !== texture) {
|
||||||
overlay.material.map = texture;
|
overlay.material.map = texture;
|
||||||
overlay.material.needsUpdate = true;
|
overlay.material.needsUpdate = true;
|
||||||
}
|
}
|
||||||
overlay.material.opacity = nextOpacity;
|
overlay.material.opacity = nextOpacity;
|
||||||
overlay.material.size =
|
overlay.material.size =
|
||||||
pointSize * getPointSizeMultiplier(marker) * sizeMultiplier;
|
(compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) *
|
||||||
|
getPointSizeMultiplier(marker) *
|
||||||
|
sizeMultiplier;
|
||||||
const positionAttribute = overlay.geometry.getAttribute("position");
|
const positionAttribute = overlay.geometry.getAttribute("position");
|
||||||
positionAttribute.setXYZ(0, marker.position.x, marker.position.y, marker.position.z);
|
positionAttribute.setXYZ(0, marker.position.x, marker.position.y, marker.position.z);
|
||||||
positionAttribute.needsUpdate = true;
|
positionAttribute.needsUpdate = true;
|
||||||
@@ -810,8 +887,9 @@ export function createInteractableLayer(options = {}) {
|
|||||||
].join(":");
|
].join(":");
|
||||||
|
|
||||||
const cameraScale = getCameraScale(camera);
|
const cameraScale = getCameraScale(camera);
|
||||||
|
const compactDotMode = shouldUseCompactDots(camera);
|
||||||
const scaleKey = usesDistanceScaling ? cameraScale.toFixed(3) : "fixed";
|
const scaleKey = usesDistanceScaling ? cameraScale.toFixed(3) : "fixed";
|
||||||
const nextStateKey = `${stateKey}:${scaleKey}`;
|
const nextStateKey = `${stateKey}:${scaleKey}:${compactDotMode ? "dots" : "icons"}`;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
nextStateKey === lastVisualStateKey &&
|
nextStateKey === lastVisualStateKey &&
|
||||||
@@ -822,12 +900,20 @@ export function createInteractableLayer(options = {}) {
|
|||||||
|
|
||||||
pointObjects.forEach((points) => {
|
pointObjects.forEach((points) => {
|
||||||
const sampleMarker = points.userData?.markers?.[0];
|
const sampleMarker = points.userData?.markers?.[0];
|
||||||
|
const nextTexture = compactDotMode
|
||||||
|
? createCompactDotTexture(sampleMarker)
|
||||||
|
: createPointTexture(points.userData?.bucketKey, points.userData?.markers || []);
|
||||||
|
if (points.material.map !== nextTexture) {
|
||||||
|
points.material.map = nextTexture;
|
||||||
|
points.material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
updatePointColors(points, compactDotMode);
|
||||||
points.visible = visible;
|
points.visible = visible;
|
||||||
points.material.opacity =
|
points.material.opacity =
|
||||||
getPointOpacity?.(sampleMarker) ??
|
getPointOpacity?.(sampleMarker) ??
|
||||||
(hasFocus ? dimmedOpacity : baseOpacity);
|
(hasFocus ? dimmedOpacity : baseOpacity);
|
||||||
points.material.size =
|
points.material.size =
|
||||||
pointSize *
|
(compactDotMode ? COMPACT_DOT_POINT_SIZE : pointSize) *
|
||||||
getPointSizeMultiplier(sampleMarker) *
|
getPointSizeMultiplier(sampleMarker) *
|
||||||
cameraScale *
|
cameraScale *
|
||||||
(hasFocus ? dimmedScale : 1);
|
(hasFocus ? dimmedScale : 1);
|
||||||
@@ -842,6 +928,7 @@ export function createInteractableLayer(options = {}) {
|
|||||||
"hover",
|
"hover",
|
||||||
hoverOpacity,
|
hoverOpacity,
|
||||||
hoverScale * cameraScale,
|
hoverScale * cameraScale,
|
||||||
|
compactDotMode,
|
||||||
);
|
);
|
||||||
const lockedPulse =
|
const lockedPulse =
|
||||||
pulse.enabled && hasFocus
|
pulse.enabled && hasFocus
|
||||||
@@ -853,6 +940,7 @@ export function createInteractableLayer(options = {}) {
|
|||||||
"locked",
|
"locked",
|
||||||
lockedOpacity,
|
lockedOpacity,
|
||||||
lockedScale * lockedPulse * cameraScale,
|
lockedScale * lockedPulse * cameraScale,
|
||||||
|
compactDotMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ import {
|
|||||||
getRelatedSatelliteIndicesForRegions,
|
getRelatedSatelliteIndicesForRegions,
|
||||||
updateRelatedSatelliteHighlights,
|
updateRelatedSatelliteHighlights,
|
||||||
updateBreathingPhase,
|
updateBreathingPhase,
|
||||||
|
updateSatelliteIdleBreathingVisual,
|
||||||
updateSatellitePointSize,
|
updateSatellitePointSize,
|
||||||
isSatelliteFrontFacing,
|
isSatelliteFrontFacing,
|
||||||
setSatelliteCamera,
|
setSatelliteCamera,
|
||||||
@@ -159,6 +160,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
clearComputeCenterData,
|
clearComputeCenterData,
|
||||||
clearComputeCenterSelection,
|
clearComputeCenterSelection,
|
||||||
|
clearComputeCenterLocationPreview,
|
||||||
formatComputeCenterCapacity,
|
formatComputeCenterCapacity,
|
||||||
formatComputeCenterLocationPrecision,
|
formatComputeCenterLocationPrecision,
|
||||||
formatComputeCenterLocationConfidence,
|
formatComputeCenterLocationConfidence,
|
||||||
@@ -174,6 +176,8 @@ import {
|
|||||||
getUnresolvedComputeCenters,
|
getUnresolvedComputeCenters,
|
||||||
loadComputeCenters,
|
loadComputeCenters,
|
||||||
setComputeCenterMarkerState,
|
setComputeCenterMarkerState,
|
||||||
|
showComputeCenterLocationPreview,
|
||||||
|
spawnSavedComputeCenterLocation,
|
||||||
toggleComputeCenters,
|
toggleComputeCenters,
|
||||||
updateComputeCenterVisualState,
|
updateComputeCenterVisualState,
|
||||||
} from "./compute-centers.js";
|
} from "./compute-centers.js";
|
||||||
@@ -205,6 +209,7 @@ import {
|
|||||||
applyImmediateView,
|
applyImmediateView,
|
||||||
focusEarthView,
|
focusEarthView,
|
||||||
getZoomLevel,
|
getZoomLevel,
|
||||||
|
getDefaultEarthZoomLevel,
|
||||||
setZoomLevel,
|
setZoomLevel,
|
||||||
showZoomStatusCapsule,
|
showZoomStatusCapsule,
|
||||||
teardownControls,
|
teardownControls,
|
||||||
@@ -369,6 +374,7 @@ const INTERACTABLE_CRUISE_CARD_ESTIMATED_WIDTH_PX = 300;
|
|||||||
const INTERACTABLE_CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420;
|
const INTERACTABLE_CRUISE_CARD_ESTIMATED_HEIGHT_PX = 420;
|
||||||
const INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX = 12;
|
const INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX = 12;
|
||||||
const INTERACTABLE_CRUISE_PRESENTATION_HIDE_MS = 220;
|
const INTERACTABLE_CRUISE_PRESENTATION_HIDE_MS = 220;
|
||||||
|
const TARGET_SWITCH_DURATION_SCALE = 1.12;
|
||||||
const MOTION_ROTATION_DELTA = 0.095;
|
const MOTION_ROTATION_DELTA = 0.095;
|
||||||
const MOTION_INERTIA_FACTOR = 0.65;
|
const MOTION_INERTIA_FACTOR = 0.65;
|
||||||
const GLOBE_DRAGGING_CLASS = "is-globe-dragging";
|
const GLOBE_DRAGGING_CLASS = "is-globe-dragging";
|
||||||
@@ -385,6 +391,8 @@ const HUD_INTERACTIVE_SELECTORS = [
|
|||||||
"#earth-stats *",
|
"#earth-stats *",
|
||||||
"#media-panel",
|
"#media-panel",
|
||||||
"#media-panel *",
|
"#media-panel *",
|
||||||
|
"#motion-debug-panel",
|
||||||
|
"#motion-debug-panel *",
|
||||||
"#mobile-drawer-shell",
|
"#mobile-drawer-shell",
|
||||||
"#mobile-drawer-shell *",
|
"#mobile-drawer-shell *",
|
||||||
];
|
];
|
||||||
@@ -442,6 +450,31 @@ function getDragRotationFactor() {
|
|||||||
return CONFIG.dragRotationFactorBase * scale;
|
return CONFIG.dragRotationFactorBase * scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTargetSwitchZoomOptions(requestedZoom) {
|
||||||
|
const currentZoom = getZoomLevel();
|
||||||
|
const defaultZoom = getDefaultEarthZoomLevel();
|
||||||
|
if (Math.abs(currentZoom - defaultZoom) <= 0.005) {
|
||||||
|
return { zoom: requestedZoom };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
zoom: currentZoom,
|
||||||
|
zoomTransitionMode: "restore-current-via-default",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusTargetSwitchView(options = {}) {
|
||||||
|
const requestedZoom = options.zoom ?? getDefaultEarthZoomLevel();
|
||||||
|
const zoomOptions = getTargetSwitchZoomOptions(requestedZoom);
|
||||||
|
const duration = zoomOptions.zoomTransitionMode === "restore-current-via-default"
|
||||||
|
? Math.round((options.duration ?? CRUISE_CONFIG.focusDurationMs) * TARGET_SWITCH_DURATION_SCALE)
|
||||||
|
: options.duration;
|
||||||
|
return focusEarthView(camera, {
|
||||||
|
...options,
|
||||||
|
...zoomOptions,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getTouchDistance(firstPoint, secondPoint) {
|
function getTouchDistance(firstPoint, secondPoint) {
|
||||||
return Math.hypot(
|
return Math.hypot(
|
||||||
secondPoint.clientX - firstPoint.clientX,
|
secondPoint.clientX - firstPoint.clientX,
|
||||||
@@ -801,7 +834,13 @@ function getMotionAnchorRectFromCenter(center, sizePx) {
|
|||||||
function getMarkerMotionScreenPoint(marker) {
|
function getMarkerMotionScreenPoint(marker) {
|
||||||
if (!marker || !camera) return null;
|
if (!marker || !camera) return null;
|
||||||
scratchSatelliteWorldPosition.copy(marker.position);
|
scratchSatelliteWorldPosition.copy(marker.position);
|
||||||
marker.parent?.localToWorld(scratchSatelliteWorldPosition);
|
if (marker.parent) {
|
||||||
|
marker.parent.localToWorld(scratchSatelliteWorldPosition);
|
||||||
|
} else {
|
||||||
|
const earth = getEarth();
|
||||||
|
earth?.updateMatrixWorld(true);
|
||||||
|
earth?.localToWorld(scratchSatelliteWorldPosition);
|
||||||
|
}
|
||||||
return getMotionScreenPointFromWorld(scratchSatelliteWorldPosition);
|
return getMotionScreenPointFromWorld(scratchSatelliteWorldPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -825,6 +864,7 @@ function getMotionCandidateScreenCoords(candidate) {
|
|||||||
const satPositions = getSatellitePositions();
|
const satPositions = getSatellitePositions();
|
||||||
const position = satPositions?.[candidate.index]?.current;
|
const position = satPositions?.[candidate.index]?.current;
|
||||||
if (satPoints?.visible && position) {
|
if (satPoints?.visible && position) {
|
||||||
|
satPoints.updateMatrixWorld(true);
|
||||||
scratchSatelliteWorldPosition.copy(position).applyMatrix4(satPoints.matrixWorld);
|
scratchSatelliteWorldPosition.copy(position).applyMatrix4(satPoints.matrixWorld);
|
||||||
return getMotionScreenPointFromWorld(scratchSatelliteWorldPosition);
|
return getMotionScreenPointFromWorld(scratchSatelliteWorldPosition);
|
||||||
}
|
}
|
||||||
@@ -1021,7 +1061,7 @@ function ensureMotionCruiseAdapter() {
|
|||||||
|
|
||||||
motionCruiseAdapter = createMotionCruiseAdapter({
|
motionCruiseAdapter = createMotionCruiseAdapter({
|
||||||
presentationController: ensurePresentationController(),
|
presentationController: ensurePresentationController(),
|
||||||
focusView: (options) => focusEarthView(camera, options),
|
focusView: focusTargetSwitchView,
|
||||||
getItems: getMotionCruiseItems,
|
getItems: getMotionCruiseItems,
|
||||||
getItemId: (item) => item?.id || null,
|
getItemId: (item) => item?.id || null,
|
||||||
resolveLatestItem: resolveLatestMotionCruiseItem,
|
resolveLatestItem: resolveLatestMotionCruiseItem,
|
||||||
@@ -1851,12 +1891,20 @@ async function previewLocationCandidate(detail) {
|
|||||||
const lat = Number(detail?.latitude);
|
const lat = Number(detail?.latitude);
|
||||||
const lon = Number(detail?.longitude);
|
const lon = Number(detail?.longitude);
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||||
|
const earth = getEarth();
|
||||||
|
if (earth && detail?.entityType === "compute_center") {
|
||||||
|
showComputeCenterLocationPreview(earth, {
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
});
|
||||||
|
}
|
||||||
interruptCruisePresentation({ resetLoop: true });
|
interruptCruisePresentation({ resetLoop: true });
|
||||||
setAutoRotate(false);
|
setAutoRotate(false);
|
||||||
await focusSearchTarget({ lat, lon }, Math.max(getZoomLevel(), 1.16));
|
await focusSearchTarget({ lat, lon }, Math.max(getZoomLevel(), 1.16));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshComputeCentersAfterLocationSave() {
|
async function refreshComputeCentersAfterLocationSave() {
|
||||||
|
const earth = getEarth();
|
||||||
if (!scene || !earth) {
|
if (!scene || !earth) {
|
||||||
console.warn("算力中心坐标已保存,但场景尚未就绪,跳过自动刷新");
|
console.warn("算力中心坐标已保存,但场景尚未就绪,跳过自动刷新");
|
||||||
return { skipped: true };
|
return { skipped: true };
|
||||||
@@ -1867,7 +1915,38 @@ async function refreshComputeCentersAfterLocationSave() {
|
|||||||
setLegendItems("computeCenters", getComputeCenterLegendItems());
|
setLegendItems("computeCenters", getComputeCenterLegendItems());
|
||||||
refreshLegend();
|
refreshLegend();
|
||||||
updateStatsSummary();
|
updateStatsSummary();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spawnComputeCenterAfterLocationSave(detail = {}) {
|
||||||
|
clearComputeCenterLocationPreview();
|
||||||
|
const earth = getEarth();
|
||||||
|
if (!earth) {
|
||||||
|
console.warn("算力中心坐标已保存,但场景尚未就绪,跳过即时生成");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sourceId = detail.entityId || detail.sourceId || detail.result?.source_id;
|
||||||
|
const result = await spawnSavedComputeCenterLocation(earth, {
|
||||||
|
sourceId,
|
||||||
|
candidate: detail.candidate,
|
||||||
|
context: detail.context,
|
||||||
|
saveResult: detail.result,
|
||||||
|
});
|
||||||
|
if (!result) return null;
|
||||||
|
toggleComputeCenters(getShowComputeCenters());
|
||||||
|
updateComputeCenterHud(result);
|
||||||
|
setLegendItems("computeCenters", getComputeCenterLegendItems());
|
||||||
|
refreshLegend();
|
||||||
|
updateStatsSummary();
|
||||||
|
syncComputeCenterUnresolvedCount(result.unresolvedCount);
|
||||||
|
if (result.marker) {
|
||||||
|
clearLockedObject();
|
||||||
|
setComputeCenterMarkerState(result.marker, "locked");
|
||||||
|
lockedObject = result.marker;
|
||||||
|
lockedObjectType = "compute_center";
|
||||||
|
}
|
||||||
showStatusMessage("算力中心坐标已保存", "success");
|
showStatusMessage("算力中心坐标已保存", "success");
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function focusSearchVessel(marker) {
|
async function focusSearchVessel(marker) {
|
||||||
@@ -2380,9 +2459,10 @@ function ensureBGPCruiseAdapter() {
|
|||||||
|
|
||||||
cruiseBGPAdapter = createBGPCruiseAdapter({
|
cruiseBGPAdapter = createBGPCruiseAdapter({
|
||||||
camera,
|
camera,
|
||||||
|
earth: () => getEarth(),
|
||||||
getMarkers: () => getBGPAnomalyMarkers(),
|
getMarkers: () => getBGPAnomalyMarkers(),
|
||||||
connector: ensureCalloutConnector(),
|
connector: ensureCalloutConnector(),
|
||||||
focusView: (options) => focusEarthView(camera, options),
|
focusView: focusTargetSwitchView,
|
||||||
setMarkerLocked: (marker) => {
|
setMarkerLocked: (marker) => {
|
||||||
setLegendMode("bgp");
|
setLegendMode("bgp");
|
||||||
setBGPMarkerState(marker, "locked");
|
setBGPMarkerState(marker, "locked");
|
||||||
@@ -2421,13 +2501,24 @@ function ensureNewsCruiseAdapter() {
|
|||||||
camera,
|
camera,
|
||||||
earth: () => getEarth(),
|
earth: () => getEarth(),
|
||||||
connector: ensureCalloutConnector(),
|
connector: ensureCalloutConnector(),
|
||||||
focusView: (options) => focusEarthView(camera, options),
|
focusView: focusTargetSwitchView,
|
||||||
});
|
});
|
||||||
|
|
||||||
return cruiseNewsAdapter;
|
return cruiseNewsAdapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInteractableCruiseInfoOptions({ reveal = true } = {}) {
|
function getInteractableCruiseInfoOptions({ reveal = true } = {}) {
|
||||||
|
const placement = getInteractableCruiseCardPlacement();
|
||||||
|
return {
|
||||||
|
x: placement.x,
|
||||||
|
y: placement.y,
|
||||||
|
absolute: true,
|
||||||
|
reveal,
|
||||||
|
anchorStable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInteractableCruiseCardPlacement() {
|
||||||
const hudScale =
|
const hudScale =
|
||||||
Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--hud-scale")) || 1;
|
Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--hud-scale")) || 1;
|
||||||
const width = Math.min(INTERACTABLE_CRUISE_CARD_ESTIMATED_WIDTH_PX * hudScale, window.innerWidth - 32);
|
const width = Math.min(INTERACTABLE_CRUISE_CARD_ESTIMATED_WIDTH_PX * hudScale, window.innerWidth - 32);
|
||||||
@@ -2443,9 +2534,8 @@ function getInteractableCruiseInfoOptions({ reveal = true } = {}) {
|
|||||||
Math.max(INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX, y),
|
Math.max(INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX, y),
|
||||||
window.innerHeight - height - INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX,
|
window.innerHeight - height - INTERACTABLE_CRUISE_CARD_SCREEN_MARGIN_PX,
|
||||||
),
|
),
|
||||||
absolute: true,
|
width,
|
||||||
reveal,
|
height,
|
||||||
anchorStable: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2512,7 +2602,7 @@ function getSatelliteCruiseItems() {
|
|||||||
async function focusInteractableCruiseItem(item, options = {}) {
|
async function focusInteractableCruiseItem(item, options = {}) {
|
||||||
const coords = getMotionCandidateFocusCoords(item?.payload);
|
const coords = getMotionCandidateFocusCoords(item?.payload);
|
||||||
if (!coords) return;
|
if (!coords) return;
|
||||||
await focusEarthView(camera, {
|
await focusTargetSwitchView({
|
||||||
lat: coords.lat,
|
lat: coords.lat,
|
||||||
lon: coords.lon,
|
lon: coords.lon,
|
||||||
rotLon: coords.lon - 270,
|
rotLon: coords.lon - 270,
|
||||||
@@ -2527,6 +2617,7 @@ async function focusInteractableCruiseItem(item, options = {}) {
|
|||||||
async function presentInteractableCruiseItem(item, { context } = {}) {
|
async function presentInteractableCruiseItem(item, { context } = {}) {
|
||||||
const candidate = item?.payload;
|
const candidate = item?.payload;
|
||||||
if (!candidate) return false;
|
if (!candidate) return false;
|
||||||
|
const cardPlacement = getInteractableCruiseCardPlacement();
|
||||||
return ensurePresentationController().present(
|
return ensurePresentationController().present(
|
||||||
{
|
{
|
||||||
id: `cruise:${item.id}`,
|
id: `cruise:${item.id}`,
|
||||||
@@ -2538,8 +2629,9 @@ async function presentInteractableCruiseItem(item, { context } = {}) {
|
|||||||
},
|
},
|
||||||
connector: {
|
connector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
animateOnReveal: true,
|
||||||
sourceProvider: () => getMotionCandidateAnchor(candidate),
|
sourceProvider: () => getMotionCandidateAnchor(candidate),
|
||||||
targetProvider: getVisiblePresentationCardTarget,
|
targetProvider: () => getVisiblePresentationCardTarget() || cardPlacement,
|
||||||
options: {
|
options: {
|
||||||
routingMode: "adaptive",
|
routingMode: "adaptive",
|
||||||
sourceGapPx: 0,
|
sourceGapPx: 0,
|
||||||
@@ -4182,11 +4274,41 @@ function setupEventListeners() {
|
|||||||
console.warn("预览候选位置失败:", error);
|
console.warn("预览候选位置失败:", error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const handleComputeCenterLocationSaved = () => {
|
const handleComputeCenterLocationSaved = (event) => {
|
||||||
refreshComputeCentersAfterLocationSave().catch((error) => {
|
const detail = event?.detail || {};
|
||||||
console.warn("刷新算力中心图层失败:", error);
|
spawnComputeCenterAfterLocationSave(detail)
|
||||||
showStatusMessage("坐标已保存,图层自动刷新失败,可手动刷新页面或重新开关图层", "warning");
|
.then((spawnResult) => {
|
||||||
});
|
if (!spawnResult) {
|
||||||
|
// No optimistic marker (e.g. scene not ready yet); rely on refresh
|
||||||
|
// for the user-visible confirmation.
|
||||||
|
return refreshComputeCentersAfterLocationSave()
|
||||||
|
.then(() => {
|
||||||
|
showStatusMessage("算力中心坐标已保存", "success");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// Swallow refresh failure: the save itself succeeded, so we
|
||||||
|
// must not surface this as a save failure.
|
||||||
|
console.warn("后台校准算力中心图层失败:", error);
|
||||||
|
showStatusMessage("坐标已保存,地图稍后同步", "info");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Optimistic marker is on screen; reconcile in the background.
|
||||||
|
refreshComputeCentersAfterLocationSave().catch((error) => {
|
||||||
|
console.warn("后台校准算力中心图层失败:", error);
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn("即时生成算力中心交互物件失败,改用后台刷新:", error);
|
||||||
|
showStatusMessage("坐标已保存,正在同步地图...", "info");
|
||||||
|
refreshComputeCentersAfterLocationSave()
|
||||||
|
.then(() => {
|
||||||
|
showStatusMessage("算力中心坐标已保存", "success");
|
||||||
|
})
|
||||||
|
.catch((refreshError) => {
|
||||||
|
console.warn("后台校准算力中心图层失败:", refreshError);
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const handleComputeCenterUnresolvedCountChange = (event) => {
|
const handleComputeCenterUnresolvedCountChange = (event) => {
|
||||||
syncComputeCenterUnresolvedCount(event?.detail?.unresolvedCount ?? event?.detail);
|
syncComputeCenterUnresolvedCount(event?.detail?.unresolvedCount ?? event?.detail);
|
||||||
@@ -4998,6 +5120,12 @@ function animate() {
|
|||||||
|
|
||||||
updateSatellitePositions(deltaTime);
|
updateSatellitePositions(deltaTime);
|
||||||
updateBreathingPhase(deltaTime);
|
updateBreathingPhase(deltaTime);
|
||||||
|
updateSatelliteIdleBreathingVisual(
|
||||||
|
!isDragging &&
|
||||||
|
!hasActiveGlobeInertia() &&
|
||||||
|
activeTouchPoints.size === 0 &&
|
||||||
|
!pinchGesture,
|
||||||
|
);
|
||||||
updateSatellitePointSize();
|
updateSatellitePointSize();
|
||||||
updateRelatedSatelliteHighlights();
|
updateRelatedSatelliteHighlights();
|
||||||
updateCelestialLayer(new Date(), camera);
|
updateCelestialLayer(new Date(), camera);
|
||||||
|
|||||||
@@ -21,10 +21,31 @@ const MIN_GESTURE_INTENSITY = 0.45;
|
|||||||
const ARM_PATTERN_INTENSITY_SCALE = 5;
|
const ARM_PATTERN_INTENSITY_SCALE = 5;
|
||||||
const WRIST_LAYER_INTENSITY_SCALE = 9;
|
const WRIST_LAYER_INTENSITY_SCALE = 9;
|
||||||
const HEAD_TILT_INTENSITY_SCALE = 12;
|
const HEAD_TILT_INTENSITY_SCALE = 12;
|
||||||
const ZOOM_OPEN_WRIST_SPREAD_FACTOR = 1.42;
|
|
||||||
const ZOOM_OPEN_WRIST_HEIGHT_TOLERANCE = 0.16;
|
|
||||||
const ZOOM_CLOSE_WRIST_SPREAD_FACTOR = 1.28;
|
const ZOOM_CLOSE_WRIST_SPREAD_FACTOR = 1.28;
|
||||||
const ZOOM_SUPPRESS_WRIST_SPREAD_FACTOR = 1.18;
|
const ZOOM_SUPPRESS_WRIST_SPREAD_FACTOR = 1.18;
|
||||||
|
// Trend-based zoom detection. Pose matching is brittle because MediaPipe
|
||||||
|
// keypoints jitter and the absolute "T-pose" pattern only matches in a
|
||||||
|
// narrow window. Track frame-to-frame motion instead: if both wrists are
|
||||||
|
// moving anti-symmetrically along the x axis (one moving outward, the other
|
||||||
|
// moving outward in the opposite direction), the user's intent is a zoom,
|
||||||
|
// regardless of where exactly the wrists end up.
|
||||||
|
const ZOOM_TREND_MIN_WRIST_DELTA = 0.010;
|
||||||
|
const ZOOM_TREND_HEIGHT_TOLERANCE = 0.18;
|
||||||
|
const ZOOM_TREND_INTENSITY_SCALE = 14;
|
||||||
|
const ZOOM_TREND_MIN_INTENSITY = 0.6;
|
||||||
|
// Left wrist must hang at least this far below the shoulder line for the
|
||||||
|
// arm to count as "at rest" -- distinguishes a deliberate single right-arm
|
||||||
|
// rotate from any two-arm or chest-height gesture in flight.
|
||||||
|
const LEFT_ARM_REST_HANGING_BELOW_SHOULDER = 0.13;
|
||||||
|
// Sustained pose-hold thresholds for continuous zoom emission while the
|
||||||
|
// user keeps their arms in a spread / closed pose. Mirror-safe (all checks
|
||||||
|
// are span-based, not direction-based) so they work regardless of whether
|
||||||
|
// the camera feed is mirrored.
|
||||||
|
const ZOOM_HOLD_HEIGHT_TOLERANCE = 0.18;
|
||||||
|
const ZOOM_HOLD_WRIST_BELOW_SHOULDER_LIMIT = 0.10;
|
||||||
|
const ZOOM_HOLD_SPREAD_FACTOR = 1.30;
|
||||||
|
const ZOOM_HOLD_CLOSE_FACTOR = 0.85;
|
||||||
|
const ZOOM_HOLD_ELBOW_OUT_FACTOR = 0.25;
|
||||||
const CAMERA_CONSTRAINTS = {
|
const CAMERA_CONSTRAINTS = {
|
||||||
video: {
|
video: {
|
||||||
facingMode: "user",
|
facingMode: "user",
|
||||||
@@ -179,39 +200,6 @@ function getRightArmPattern(rightShoulder, rightElbow, rightWrist) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getZoomPattern(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth) {
|
|
||||||
const leftUpper = vectorBetween(leftShoulder, leftElbow);
|
|
||||||
const leftTerminal = vectorBetween(leftElbow, leftWrist);
|
|
||||||
const rightUpper = vectorBetween(rightShoulder, rightElbow);
|
|
||||||
const rightTerminal = vectorBetween(rightElbow, rightWrist);
|
|
||||||
if (!leftUpper || !leftTerminal || !rightUpper || !rightTerminal) return null;
|
|
||||||
|
|
||||||
const leftWristOutside = leftWrist.x < leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH;
|
|
||||||
const rightWristOutside = rightWrist.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH;
|
|
||||||
const leftArmOut =
|
|
||||||
leftWristOutside &&
|
|
||||||
leftElbow.x <= leftShoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.25;
|
|
||||||
const rightArmOut =
|
|
||||||
rightWristOutside &&
|
|
||||||
rightElbow.x >= rightShoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.25;
|
|
||||||
const leftForearmIn = leftWrist.x > leftElbow.x + ARM_PATTERN_MIN_SIDE_REACH * 0.5;
|
|
||||||
const rightForearmIn = rightWrist.x < rightElbow.x - ARM_PATTERN_MIN_SIDE_REACH * 0.5;
|
|
||||||
const wristsApart = Math.abs(rightWrist.x - leftWrist.x);
|
|
||||||
const wristsCloseToCenter = wristsApart < shoulderWidth * ZOOM_CLOSE_WRIST_SPREAD_FACTOR;
|
|
||||||
const wristsHeightAligned = Math.abs(rightWrist.y - leftWrist.y) <= ZOOM_OPEN_WRIST_HEIGHT_TOLERANCE;
|
|
||||||
const elbowsOut =
|
|
||||||
leftElbow.x < leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH * 0.5 &&
|
|
||||||
rightElbow.x > rightShoulder.x + ARM_PATTERN_MIN_SIDE_REACH * 0.5;
|
|
||||||
|
|
||||||
if (leftArmOut && rightArmOut && wristsHeightAligned && wristsApart > shoulderWidth * ZOOM_OPEN_WRIST_SPREAD_FACTOR) {
|
|
||||||
return { gesture: "zoom_in", confidence: 0.82, intensity: 0.82 };
|
|
||||||
}
|
|
||||||
if (elbowsOut && leftForearmIn && rightForearmIn && wristsCloseToCenter) {
|
|
||||||
return { gesture: "zoom_out", confidence: 0.78, intensity: 0.72 };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isZoomCandidatePose(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth) {
|
function isZoomCandidatePose(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth) {
|
||||||
const wristsApart = Math.abs(rightWrist.x - leftWrist.x);
|
const wristsApart = Math.abs(rightWrist.x - leftWrist.x);
|
||||||
const bothHandsOutside =
|
const bothHandsOutside =
|
||||||
@@ -234,6 +222,101 @@ function isZoomCandidatePose(leftShoulder, leftElbow, leftWrist, rightShoulder,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The single-arm rotate detector only looks at the right arm and cannot tell
|
||||||
|
// whether the user is mid-way through a two-arm gesture. Because the right
|
||||||
|
// wrist crosses its rotate trigger one or two frames before the left wrist
|
||||||
|
// catches up to the zoom threshold, rotate routinely fires as the user starts
|
||||||
|
// to spread their arms. Flip the predicate: a deliberate single right-arm
|
||||||
|
// wave keeps the left wrist clearly hanging at the side, so refuse to emit
|
||||||
|
// any right-arm rotate unless we can verify the left arm is at rest (wrist
|
||||||
|
// hanging well below the shoulder AND elbow + wrist sitting near the body).
|
||||||
|
// Any ambiguous left-arm state -- raised, extending outward, or held at
|
||||||
|
// chest level -- yields no gesture, letting getZoomHoldPose handle the next
|
||||||
|
// frame instead.
|
||||||
|
function isLeftArmAtRest(leftShoulder, leftElbow, leftWrist) {
|
||||||
|
const hangingBelowShoulder = leftWrist.y >= leftShoulder.y + LEFT_ARM_REST_HANGING_BELOW_SHOULDER;
|
||||||
|
const wristNearBody = leftWrist.x >= leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH;
|
||||||
|
const elbowNearBody = leftElbow.x >= leftShoulder.x - ARM_PATTERN_MIN_SIDE_REACH;
|
||||||
|
return hangingBelowShoulder && wristNearBody && elbowNearBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect a two-arm zoom intent purely from frame-to-frame motion.
|
||||||
|
// Mirror-safe: measures the change in span between the wrists, not the
|
||||||
|
// per-wrist x direction. Spreading widens the span and triggers zoom_in
|
||||||
|
// regardless of whether the camera feed is mirrored; closing shrinks the
|
||||||
|
// span and triggers zoom_out. Both wrists must be actively moving (each
|
||||||
|
// crosses the noise floor) to rule out single-arm drift.
|
||||||
|
function getZoomTrend(leftWrist, rightWrist, previousLeftWrist, previousRightWrist) {
|
||||||
|
if (!previousLeftWrist || !previousRightWrist) return null;
|
||||||
|
const heightDelta = Math.abs(rightWrist.y - leftWrist.y);
|
||||||
|
if (heightDelta > ZOOM_TREND_HEIGHT_TOLERANCE) return null;
|
||||||
|
|
||||||
|
const leftMoved = Math.abs(leftWrist.x - previousLeftWrist.x);
|
||||||
|
const rightMoved = Math.abs(rightWrist.x - previousRightWrist.x);
|
||||||
|
if (leftMoved < ZOOM_TREND_MIN_WRIST_DELTA || rightMoved < ZOOM_TREND_MIN_WRIST_DELTA) return null;
|
||||||
|
|
||||||
|
const currentSpread = Math.abs(rightWrist.x - leftWrist.x);
|
||||||
|
const previousSpread = Math.abs(previousRightWrist.x - previousLeftWrist.x);
|
||||||
|
const spreadDelta = currentSpread - previousSpread;
|
||||||
|
const minSpreadDelta = ZOOM_TREND_MIN_WRIST_DELTA * 2;
|
||||||
|
const combinedSpeed = leftMoved + rightMoved;
|
||||||
|
const intensity = Math.min(1, Math.max(ZOOM_TREND_MIN_INTENSITY, combinedSpeed * ZOOM_TREND_INTENSITY_SCALE));
|
||||||
|
|
||||||
|
if (spreadDelta > minSpreadDelta) {
|
||||||
|
return { gesture: "zoom_in", confidence: 0.88, intensity };
|
||||||
|
}
|
||||||
|
if (spreadDelta < -minSpreadDelta) {
|
||||||
|
return { gesture: "zoom_out", confidence: 0.86, intensity };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror-safe sustained-pose detector. Decides whether the user is currently
|
||||||
|
// holding a "spread" or "closed" pose so zoom_in / zoom_out can keep firing
|
||||||
|
// while no motion is happening (trend would otherwise stop emitting).
|
||||||
|
// - heightDelta gate rules out one-arm-up-one-arm-down gestures.
|
||||||
|
// - wristsRaised gate ensures the wrists are at chest level or above
|
||||||
|
// (excludes "hands hanging at the hips" which would coincidentally have
|
||||||
|
// a small span).
|
||||||
|
// - span > shoulderWidth * 1.30 -> zoom_in (mirror-safe via Math.abs).
|
||||||
|
// - closed pose additionally requires both elbows clearly outside the
|
||||||
|
// shoulder line (forming a "hug"), distinguishing it from arms relaxed
|
||||||
|
// at the body's centerline.
|
||||||
|
function getZoomHoldPose(
|
||||||
|
leftShoulder,
|
||||||
|
leftElbow,
|
||||||
|
leftWrist,
|
||||||
|
rightShoulder,
|
||||||
|
rightElbow,
|
||||||
|
rightWrist,
|
||||||
|
shoulderWidth,
|
||||||
|
) {
|
||||||
|
const heightDelta = Math.abs(rightWrist.y - leftWrist.y);
|
||||||
|
if (heightDelta > ZOOM_HOLD_HEIGHT_TOLERANCE) return null;
|
||||||
|
|
||||||
|
const avgShoulderY = (leftShoulder.y + rightShoulder.y) / 2;
|
||||||
|
const wristsRaised =
|
||||||
|
leftWrist.y <= avgShoulderY + ZOOM_HOLD_WRIST_BELOW_SHOULDER_LIMIT &&
|
||||||
|
rightWrist.y <= avgShoulderY + ZOOM_HOLD_WRIST_BELOW_SHOULDER_LIMIT;
|
||||||
|
if (!wristsRaised) return null;
|
||||||
|
|
||||||
|
const span = Math.abs(rightWrist.x - leftWrist.x);
|
||||||
|
|
||||||
|
if (span > shoulderWidth * ZOOM_HOLD_SPREAD_FACTOR) {
|
||||||
|
return { gesture: "zoom_in", confidence: 0.82, intensity: 0.8 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftElbowSpread = Math.abs(leftElbow.x - leftShoulder.x);
|
||||||
|
const rightElbowSpread = Math.abs(rightElbow.x - rightShoulder.x);
|
||||||
|
const elbowsOutward =
|
||||||
|
leftElbowSpread > shoulderWidth * ZOOM_HOLD_ELBOW_OUT_FACTOR &&
|
||||||
|
rightElbowSpread > shoulderWidth * ZOOM_HOLD_ELBOW_OUT_FACTOR;
|
||||||
|
if (elbowsOutward && span < shoulderWidth * ZOOM_HOLD_CLOSE_FACTOR) {
|
||||||
|
return { gesture: "zoom_out", confidence: 0.80, intensity: 0.7 };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function applyPoseLatch(observation, state) {
|
function applyPoseLatch(observation, state) {
|
||||||
if (!state || !observation) return observation;
|
if (!state || !observation) return observation;
|
||||||
if (state.activePatternGesture === observation.gesture) return null;
|
if (state.activePatternGesture === observation.gesture) return null;
|
||||||
@@ -252,8 +335,19 @@ function recognizeGesture(joints, previousJoints, options = {}) {
|
|||||||
const leftShoulder = getJoint(joints, "left_shoulder");
|
const leftShoulder = getJoint(joints, "left_shoulder");
|
||||||
const rightShoulder = getJoint(joints, "right_shoulder");
|
const rightShoulder = getJoint(joints, "right_shoulder");
|
||||||
const previousLeftWrist = getJoint(previousJoints, "left_wrist");
|
const previousLeftWrist = getJoint(previousJoints, "left_wrist");
|
||||||
|
const previousRightWrist = getJoint(previousJoints, "right_wrist");
|
||||||
if (!leftWrist || !rightWrist || !leftElbow || !rightElbow || !leftShoulder || !rightShoulder) return null;
|
if (!leftWrist || !rightWrist || !leftElbow || !rightElbow || !leftShoulder || !rightShoulder) return null;
|
||||||
|
|
||||||
|
// Trend-based zoom runs first: anti-symmetric wrist motion expresses
|
||||||
|
// the user's intent directly and is far more reliable than waiting for
|
||||||
|
// an absolute pose to match. It also bypasses the layer / focus / rotate
|
||||||
|
// detectors, which would otherwise intercept mid-spread frames.
|
||||||
|
const trend = getZoomTrend(leftWrist, rightWrist, previousLeftWrist, previousRightWrist);
|
||||||
|
if (trend) {
|
||||||
|
if (state) state.activePatternGesture = trend.gesture;
|
||||||
|
return trend;
|
||||||
|
}
|
||||||
|
|
||||||
const shoulderWidth = Math.max(0.08, Math.abs(rightShoulder.x - leftShoulder.x));
|
const shoulderWidth = Math.max(0.08, Math.abs(rightShoulder.x - leftShoulder.x));
|
||||||
const leftRaised = leftWrist.y < leftShoulder.y - 0.05;
|
const leftRaised = leftWrist.y < leftShoulder.y - 0.05;
|
||||||
const rightRaised = rightWrist.y < rightShoulder.y - 0.05;
|
const rightRaised = rightWrist.y < rightShoulder.y - 0.05;
|
||||||
@@ -275,14 +369,49 @@ function recognizeGesture(joints, previousJoints, options = {}) {
|
|||||||
return { gesture: "focus_next", confidence: 0.78, intensity: Math.min(1, Math.abs(headTiltY) * HEAD_TILT_INTENSITY_SCALE) };
|
return { gesture: "focus_next", confidence: 0.78, intensity: Math.min(1, Math.abs(headTiltY) * HEAD_TILT_INTENSITY_SCALE) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const pattern =
|
// Pose-based zoom hold: while the user sustains a spread (zoom_in) or
|
||||||
getZoomPattern(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth) ||
|
// closed (zoom_out) pose without further motion, keep emitting the same
|
||||||
(
|
// zoom direction every frame. Bypasses the pattern latch so the gesture
|
||||||
isZoomCandidatePose(leftShoulder, leftElbow, leftWrist, rightShoulder, rightElbow, rightWrist, shoulderWidth)
|
// can fire repeatedly; downstream cooldownMs (120ms) rate-limits to
|
||||||
? null
|
// ~8 emissions/sec, which produces smooth continuous zooming on the globe
|
||||||
: getRightArmPattern(rightShoulder, rightElbow, rightWrist)
|
// until the user changes their pose. Uses the mirror-safe span detector
|
||||||
);
|
// so it works on non-mirrored camera feeds where the absolute left/right
|
||||||
if (pattern) return applyPoseLatch(pattern, state);
|
// pose checks would otherwise fail.
|
||||||
|
const zoomPattern = getZoomHoldPose(
|
||||||
|
leftShoulder,
|
||||||
|
leftElbow,
|
||||||
|
leftWrist,
|
||||||
|
rightShoulder,
|
||||||
|
rightElbow,
|
||||||
|
rightWrist,
|
||||||
|
shoulderWidth,
|
||||||
|
);
|
||||||
|
if (zoomPattern) {
|
||||||
|
if (state) state.activePatternGesture = zoomPattern.gesture;
|
||||||
|
return zoomPattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two safeguards must both hold before a single right-arm rotate fires:
|
||||||
|
// (1) zoom is not currently a likely interpretation of the pose,
|
||||||
|
// (2) the left arm is verifiably at rest. This kills the right-leads-left
|
||||||
|
// race that previously emitted a stray rotate at the start of a spread.
|
||||||
|
const rotateAllowed =
|
||||||
|
!isZoomCandidatePose(
|
||||||
|
leftShoulder,
|
||||||
|
leftElbow,
|
||||||
|
leftWrist,
|
||||||
|
rightShoulder,
|
||||||
|
rightElbow,
|
||||||
|
rightWrist,
|
||||||
|
shoulderWidth,
|
||||||
|
) && isLeftArmAtRest(leftShoulder, leftElbow, leftWrist);
|
||||||
|
const rotatePattern = rotateAllowed
|
||||||
|
? getRightArmPattern(rightShoulder, rightElbow, rightWrist)
|
||||||
|
: null;
|
||||||
|
// Rotate stays latched: one deliberate wave = one rotation step. Without
|
||||||
|
// the latch, holding the arm out would spin the globe continuously, which
|
||||||
|
// is the opposite of what the user wants for navigation.
|
||||||
|
if (rotatePattern) return applyPoseLatch(rotatePattern, state);
|
||||||
if (state) state.activePatternGesture = null;
|
if (state) state.activePatternGesture = null;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,6 +232,11 @@ export function createMotionControlAdapter(options = {}) {
|
|||||||
activeProvider?.stop?.();
|
activeProvider?.stop?.();
|
||||||
activeProvider = null;
|
activeProvider = null;
|
||||||
connected = false;
|
connected = false;
|
||||||
|
dispatchWindowEvent(MOTION_DEBUG_VIDEO_SOURCE_EVENT, {
|
||||||
|
provider: selectedProvider,
|
||||||
|
source: null,
|
||||||
|
active: false,
|
||||||
|
});
|
||||||
emitState({ provider: selectedProvider, connected: false });
|
emitState({ provider: selectedProvider, connected: false });
|
||||||
},
|
},
|
||||||
isConnected() {
|
isConnected() {
|
||||||
|
|||||||
@@ -506,6 +506,24 @@ describe("browser camera gesture semantics", () => {
|
|||||||
expect(recognizeGesture(current, current)?.gesture).toBe("zoom_in");
|
expect(recognizeGesture(current, current)?.gesture).toBe("zoom_in");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("open arms within a 30 degree vertical fan trigger zoom in", () => {
|
||||||
|
const upwardFan = createPoseJoints({
|
||||||
|
left_elbow: { id: "left_elbow", x: 0.35, y: 0.48, confidence: 1 },
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.29, y: 0.43, confidence: 1 },
|
||||||
|
right_elbow: { id: "right_elbow", x: 0.65, y: 0.52, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.72, y: 0.57, confidence: 1 },
|
||||||
|
});
|
||||||
|
const downwardFan = createPoseJoints({
|
||||||
|
left_elbow: { id: "left_elbow", x: 0.35, y: 0.52, confidence: 1 },
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.29, y: 0.57, confidence: 1 },
|
||||||
|
right_elbow: { id: "right_elbow", x: 0.65, y: 0.48, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.72, y: 0.43, confidence: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recognizeGesture(upwardFan, upwardFan)?.gesture).toBe("zoom_in");
|
||||||
|
expect(recognizeGesture(downwardFan, downwardFan)?.gesture).toBe("zoom_in");
|
||||||
|
});
|
||||||
|
|
||||||
test("near zoom-in pose suppresses right-arm rotate while the second hand catches up", () => {
|
test("near zoom-in pose suppresses right-arm rotate while the second hand catches up", () => {
|
||||||
const current = createPoseJoints({
|
const current = createPoseJoints({
|
||||||
left_elbow: { id: "left_elbow", x: 0.4, y: 0.5, confidence: 1 },
|
left_elbow: { id: "left_elbow", x: 0.4, y: 0.5, confidence: 1 },
|
||||||
@@ -527,6 +545,188 @@ describe("browser camera gesture semantics", () => {
|
|||||||
|
|
||||||
expect(recognizeGesture(current, current)?.gesture).toBe("zoom_out");
|
expect(recognizeGesture(current, current)?.gesture).toBe("zoom_out");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Two-arm spread starts asymmetrically: the right wrist crosses the rotate
|
||||||
|
// trigger threshold a frame or two before the left wrist catches up. The
|
||||||
|
// mirror-safe span-based pose detector recognises this frame as a spread
|
||||||
|
// and emits zoom_in instead of letting the stale single-arm rotate fire.
|
||||||
|
test("mid-spread emits zoom_in (not a stray right-arm rotate) while the left arm is still extending", () => {
|
||||||
|
const midSpread = createPoseJoints({
|
||||||
|
right_elbow: { id: "right_elbow", x: 0.66, y: 0.5, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.72, y: 0.5, confidence: 1 },
|
||||||
|
left_elbow: { id: "left_elbow", x: 0.36, y: 0.5, confidence: 1 },
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.38, y: 0.55, confidence: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = recognizeGesture(midSpread, midSpread);
|
||||||
|
expect(result?.gesture).toBe("zoom_in");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Earliest-spread case: the left wrist has just started to lift toward
|
||||||
|
// shoulder height while still sitting at the body line. The right wrist has
|
||||||
|
// already crossed the rotate threshold. The mirror-safe pose detector
|
||||||
|
// recognises the wide span and fires zoom_in; the at-rest gate ensures
|
||||||
|
// rotate cannot fire in this configuration either.
|
||||||
|
test("early-spread frame fires zoom_in instead of a stray right-arm rotate", () => {
|
||||||
|
const earlySpread = createPoseJoints({
|
||||||
|
right_elbow: { id: "right_elbow", x: 0.65, y: 0.5, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.72, y: 0.5, confidence: 1 },
|
||||||
|
left_elbow: { id: "left_elbow", x: 0.41, y: 0.55, confidence: 1 },
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.42, y: 0.58, confidence: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = recognizeGesture(earlySpread, earlySpread);
|
||||||
|
expect(result?.gesture).toBe("zoom_in");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The suppressor must not over-fire: a deliberate single right-arm wave
|
||||||
|
// with the left arm at rest still needs to map to a rotate gesture.
|
||||||
|
test("right-arm wave with the left arm at rest still triggers rotate_left", () => {
|
||||||
|
const wave = createPoseJoints({
|
||||||
|
right_elbow: { id: "right_elbow", x: 0.65, y: 0.5, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.74, y: 0.5, confidence: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recognizeGesture(wave, wave)?.gesture).toBe("rotate_left");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trend-based zoom: anti-symmetric wrist motion is the strongest signal of
|
||||||
|
// intent. Pose matching alone is brittle because MediaPipe keypoints
|
||||||
|
// jitter; tracking direction-of-motion catches the gesture as soon as it
|
||||||
|
// starts.
|
||||||
|
test("wrists drifting apart trigger zoom_in via trend detection", () => {
|
||||||
|
const previous = createPoseJoints({
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.42, y: 0.55, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.58, y: 0.55, confidence: 1 },
|
||||||
|
});
|
||||||
|
const current = createPoseJoints({
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.38, y: 0.55, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.62, y: 0.55, confidence: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_in");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wrists drifting together trigger zoom_out via trend detection", () => {
|
||||||
|
const previous = createPoseJoints({
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.30, y: 0.55, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.70, y: 0.55, confidence: 1 },
|
||||||
|
});
|
||||||
|
const current = createPoseJoints({
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.34, y: 0.55, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.66, y: 0.55, confidence: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_out");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Single-arm motion (right wrist moving while left wrist is stationary)
|
||||||
|
// must NOT trip the trend zoom — only anti-symmetric motion of both
|
||||||
|
// wrists qualifies.
|
||||||
|
test("single-arm motion does not trigger trend-based zoom", () => {
|
||||||
|
const previous = createPoseJoints({
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.58, y: 0.55, confidence: 1 },
|
||||||
|
});
|
||||||
|
const current = createPoseJoints({
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.70, y: 0.55, confidence: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = recognizeGesture(current, previous);
|
||||||
|
expect(result?.gesture).not.toBe("zoom_in");
|
||||||
|
expect(result?.gesture).not.toBe("zoom_out");
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the two wrists are at very different heights (one resting, one
|
||||||
|
// raised), trend detection must NOT fire — that's a one-arm gesture.
|
||||||
|
test("trend zoom requires the wrists to be at roughly the same height", () => {
|
||||||
|
const previous = createPoseJoints({
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.42, y: 0.80, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.58, y: 0.30, confidence: 1 },
|
||||||
|
});
|
||||||
|
const current = createPoseJoints({
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.38, y: 0.80, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.62, y: 0.30, confidence: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = recognizeGesture(current, previous);
|
||||||
|
expect(result?.gesture).not.toBe("zoom_in");
|
||||||
|
expect(result?.gesture).not.toBe("zoom_out");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zoom is a sustained gesture: while the user holds a spread T-pose the
|
||||||
|
// recognizer must keep emitting zoom_in every frame so the globe keeps
|
||||||
|
// zooming. This is unlike rotate, which should emit once per wave.
|
||||||
|
test("holding a spread pose emits zoom_in on every frame", () => {
|
||||||
|
const state = {};
|
||||||
|
const spread = createPoseJoints({
|
||||||
|
left_elbow: { id: "left_elbow", x: 0.36, y: 0.5, confidence: 1 },
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.34, y: 0.4, confidence: 1 },
|
||||||
|
right_elbow: { id: "right_elbow", x: 0.64, y: 0.5, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.66, y: 0.4, confidence: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recognizeGesture(spread, spread, { state })?.gesture).toBe("zoom_in");
|
||||||
|
expect(recognizeGesture(spread, spread, { state })?.gesture).toBe("zoom_in");
|
||||||
|
expect(recognizeGesture(spread, spread, { state })?.gesture).toBe("zoom_in");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirror-safe trend: on a non-mirrored camera feed the subject's anatomical
|
||||||
|
// left arm appears on the image right (left_shoulder.x > right_shoulder.x).
|
||||||
|
// Spreading the arms must still fire zoom_in (not zoom_out) because the
|
||||||
|
// span between the wrists grows regardless of camera orientation.
|
||||||
|
test("non-mirrored camera: spreading wrists still triggers zoom_in", () => {
|
||||||
|
const previous = createPoseJoints({
|
||||||
|
// Swapped layout: anatomical left on image right
|
||||||
|
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
|
||||||
|
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.58, y: 0.55, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.42, y: 0.55, confidence: 1 },
|
||||||
|
});
|
||||||
|
const current = createPoseJoints({
|
||||||
|
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
|
||||||
|
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
|
||||||
|
// Subject's left arm extending right in the image; subject's right
|
||||||
|
// arm extending left in the image. Span widens either way.
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.62, y: 0.55, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.38, y: 0.55, confidence: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_in");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirror-safe trend: on the same non-mirrored layout, hands coming together
|
||||||
|
// must still fire zoom_out.
|
||||||
|
test("non-mirrored camera: closing wrists still triggers zoom_out", () => {
|
||||||
|
const previous = createPoseJoints({
|
||||||
|
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
|
||||||
|
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.70, y: 0.55, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.30, y: 0.55, confidence: 1 },
|
||||||
|
});
|
||||||
|
const current = createPoseJoints({
|
||||||
|
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
|
||||||
|
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.66, y: 0.55, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.34, y: 0.55, confidence: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_out");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zoom_out must also be sustained — closing the hands and holding
|
||||||
|
// continues to zoom out.
|
||||||
|
test("holding a closed pose emits zoom_out on every frame", () => {
|
||||||
|
const state = {};
|
||||||
|
const closed = createPoseJoints({
|
||||||
|
left_elbow: { id: "left_elbow", x: 0.34, y: 0.55, confidence: 1 },
|
||||||
|
left_wrist: { id: "left_wrist", x: 0.46, y: 0.58, confidence: 1 },
|
||||||
|
right_elbow: { id: "right_elbow", x: 0.66, y: 0.55, confidence: 1 },
|
||||||
|
right_wrist: { id: "right_wrist", x: 0.54, y: 0.58, confidence: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recognizeGesture(closed, closed, { state })?.gesture).toBe("zoom_out");
|
||||||
|
expect(recognizeGesture(closed, closed, { state })?.gesture).toBe("zoom_out");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("browser camera provider", () => {
|
describe("browser camera provider", () => {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ const DEBUG_PANEL_ID = "motion-debug-panel";
|
|||||||
const DEBUG_CANVAS_ID = "motion-debug-canvas";
|
const DEBUG_CANVAS_ID = "motion-debug-canvas";
|
||||||
const DEBUG_STATUS_ID = "motion-debug-status";
|
const DEBUG_STATUS_ID = "motion-debug-status";
|
||||||
const DEBUG_MATCH_ID = "motion-debug-match";
|
const DEBUG_MATCH_ID = "motion-debug-match";
|
||||||
const DEBUG_PAUSE_ID = "motion-debug-pause";
|
|
||||||
const DEBUG_CLOSE_SELECTOR = "[data-motion-debug-close]";
|
const DEBUG_CLOSE_SELECTOR = "[data-motion-debug-close]";
|
||||||
|
const DEBUG_PAUSE_SELECTOR = "[data-motion-recognition-pause-toggle]";
|
||||||
const MOBILE_MOUNT_ID = "mobile-motion-debug-mount";
|
const MOBILE_MOUNT_ID = "mobile-motion-debug-mount";
|
||||||
const FALLBACK_CANVAS_WIDTH = 320;
|
const FALLBACK_CANVAS_WIDTH = 320;
|
||||||
const FALLBACK_CANVAS_HEIGHT = 220;
|
const FALLBACK_CANVAS_HEIGHT = 220;
|
||||||
@@ -79,16 +79,21 @@ function bindPanelControls() {
|
|||||||
if (controlsBound || !(panel instanceof HTMLElement)) return;
|
if (controlsBound || !(panel instanceof HTMLElement)) return;
|
||||||
controlsBound = true;
|
controlsBound = true;
|
||||||
|
|
||||||
const closeButton = panel.querySelector(DEBUG_CLOSE_SELECTOR);
|
panel.addEventListener("click", (event) => {
|
||||||
closeButton?.addEventListener?.("click", (event) => {
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
|
if (!target?.closest(DEBUG_CLOSE_SELECTOR)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
dispatchWindowEvent(MOTION_DEBUG_CLOSE_EVENT);
|
dispatchWindowEvent(MOTION_DEBUG_CLOSE_EVENT);
|
||||||
});
|
});
|
||||||
|
|
||||||
const pauseInput = document.getElementById(DEBUG_PAUSE_ID);
|
panel.addEventListener("change", (event) => {
|
||||||
pauseInput?.addEventListener?.("change", () => {
|
const target = event.target;
|
||||||
recognitionPaused = pauseInput.checked === true;
|
if (!(target instanceof HTMLInputElement) || !target.matches(DEBUG_PAUSE_SELECTOR)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
recognitionPaused = target.checked === true;
|
||||||
dispatchWindowEvent(MOTION_RECOGNITION_PAUSE_EVENT, { paused: recognitionPaused });
|
dispatchWindowEvent(MOTION_RECOGNITION_PAUSE_EVENT, { paused: recognitionPaused });
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
@@ -282,6 +287,11 @@ function render() {
|
|||||||
panel.classList.toggle("hud-panel-hidden", !visible);
|
panel.classList.toggle("hud-panel-hidden", !visible);
|
||||||
panel.classList.toggle("is-motion-matched", Boolean(lastFrame?.matchedGesture));
|
panel.classList.toggle("is-motion-matched", Boolean(lastFrame?.matchedGesture));
|
||||||
panel.classList.toggle("is-motion-recognition-paused", recognitionPaused);
|
panel.classList.toggle("is-motion-recognition-paused", recognitionPaused);
|
||||||
|
panel.querySelectorAll(DEBUG_PAUSE_SELECTOR).forEach((input) => {
|
||||||
|
if (input instanceof HTMLInputElement) {
|
||||||
|
input.checked = recognitionPaused;
|
||||||
|
}
|
||||||
|
});
|
||||||
const providerLabel = getProviderLabel(provider);
|
const providerLabel = getProviderLabel(provider);
|
||||||
const pauseSuffix = recognitionPaused ? " · 匹配已暂停" : "";
|
const pauseSuffix = recognitionPaused ? " · 匹配已暂停" : "";
|
||||||
setText(statusEl, connected ? `${providerLabel}已连接${pauseSuffix}` : `${providerLabel}未连接${pauseSuffix}`);
|
setText(statusEl, connected ? `${providerLabel}已连接${pauseSuffix}` : `${providerLabel}未连接${pauseSuffix}`);
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export class PresentationController {
|
|||||||
const { request } = this.active;
|
const { request } = this.active;
|
||||||
const connector = this.getConnectorInstance(request);
|
const connector = this.getConnectorInstance(request);
|
||||||
if (!connector || request.connector?.enabled !== true) return false;
|
if (!connector || request.connector?.enabled !== true) return false;
|
||||||
|
if (!animate && connector.isAnimating?.()) return true;
|
||||||
const path = this.getConnectorPath(request);
|
const path = this.getConnectorPath(request);
|
||||||
if (!path) {
|
if (!path) {
|
||||||
connector.hide?.();
|
connector.hide?.();
|
||||||
@@ -150,17 +151,18 @@ export class PresentationController {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const animateOnReveal = request.connector?.animateOnReveal === true;
|
||||||
const connectorReady =
|
const connectorReady =
|
||||||
request.connector?.enabled === true
|
request.connector?.enabled === true && !animateOnReveal
|
||||||
? await this.waitForConnector(request, context)
|
? await this.waitForConnector(request, context)
|
||||||
: false;
|
: request.connector?.enabled === true;
|
||||||
|
|
||||||
if (!isCurrentContext(context) || this.active?.request !== request) {
|
if (!isCurrentContext(context) || this.active?.request !== request) {
|
||||||
this.dismiss("interrupted");
|
this.dismiss("interrupted");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connectorReady) {
|
if (connectorReady && !animateOnReveal) {
|
||||||
const drawMs = Number(request.connector?.drawMs) || DEFAULT_CONNECTOR_DRAW_MS;
|
const drawMs = Number(request.connector?.drawMs) || DEFAULT_CONNECTOR_DRAW_MS;
|
||||||
const drawCompleted = await waitForContext(context, drawMs);
|
const drawCompleted = await waitForContext(context, drawMs);
|
||||||
if (!drawCompleted || this.active?.request !== request) {
|
if (!drawCompleted || this.active?.request !== request) {
|
||||||
@@ -176,7 +178,17 @@ export class PresentationController {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update({ animate: !connectorReady });
|
const finalConnectorAnimated = this.update({
|
||||||
|
animate: animateOnReveal || !connectorReady,
|
||||||
|
});
|
||||||
|
if (animateOnReveal && finalConnectorAnimated) {
|
||||||
|
const drawMs = Number(request.connector?.drawMs) || DEFAULT_CONNECTOR_DRAW_MS;
|
||||||
|
const drawCompleted = await waitForContext(context, drawMs);
|
||||||
|
if (!drawCompleted || this.active?.request !== request) {
|
||||||
|
this.dismiss("interrupted");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
this.scheduleLifetime(request);
|
this.scheduleLifetime(request);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function createController(options = {}) {
|
|||||||
calls.push("connector:render");
|
calls.push("connector:render");
|
||||||
return options.connectorReady ?? true;
|
return options.connectorReady ?? true;
|
||||||
},
|
},
|
||||||
|
isAnimating: () => options.connectorAnimating === true,
|
||||||
hide: () => calls.push("connector:hide"),
|
hide: () => calls.push("connector:hide"),
|
||||||
};
|
};
|
||||||
const controller = new PresentationController({
|
const controller = new PresentationController({
|
||||||
@@ -126,6 +127,17 @@ describe("PresentationController", () => {
|
|||||||
expect(calls).toEqual(["connector:render"]);
|
expect(calls).toEqual(["connector:render"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("update does not interrupt an active connector draw animation", async () => {
|
||||||
|
const { calls, controller } = createController({ connectorAnimating: true });
|
||||||
|
|
||||||
|
await controller.present(createRequest({ calls }));
|
||||||
|
calls.length = 0;
|
||||||
|
const updated = controller.update();
|
||||||
|
|
||||||
|
expect(updated).toBe(true);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("rect source anchors are passed as center point plus sourceRect", async () => {
|
test("rect source anchors are passed as center point plus sourceRect", async () => {
|
||||||
const { controller, pathCalls } = createController();
|
const { controller, pathCalls } = createController();
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ let positionUpdateAccumulator = 0;
|
|||||||
let satelliteCapacity = 0;
|
let satelliteCapacity = 0;
|
||||||
let satelliteSatrecCache = new Map();
|
let satelliteSatrecCache = new Map();
|
||||||
let satelliteDisplayStyle = DEFAULT_SATELLITE_DISPLAY_STYLE;
|
let satelliteDisplayStyle = DEFAULT_SATELLITE_DISPLAY_STYLE;
|
||||||
|
let satelliteIdleBreathingEnabled = true;
|
||||||
|
|
||||||
const GROUND_FOOTPRINT_RENDER_ORDER = 3;
|
const GROUND_FOOTPRINT_RENDER_ORDER = 3;
|
||||||
|
|
||||||
@@ -127,6 +128,8 @@ const FALLBACK_TRAIL_ALPHA_END = 0.8;
|
|||||||
const DOT_TEXTURE_SIZE = 32;
|
const DOT_TEXTURE_SIZE = 32;
|
||||||
const POSITION_UPDATE_INTERVAL_MS = 250;
|
const POSITION_UPDATE_INTERVAL_MS = 250;
|
||||||
const BACKGROUND_TRAIL_RESET_DELTA_MS = 2000;
|
const BACKGROUND_TRAIL_RESET_DELTA_MS = 2000;
|
||||||
|
const SATELLITE_TWINKLE_SECONDARY_SPEED = 1.73;
|
||||||
|
const SATELLITE_TWINKLE_SECONDARY_WEIGHT = 0.28;
|
||||||
const DIMMED_SATELLITE_BRIGHTNESS = 0.42;
|
const DIMMED_SATELLITE_BRIGHTNESS = 0.42;
|
||||||
const DIMMED_SATELLITE_TRAIL_BRIGHTNESS = 0.24;
|
const DIMMED_SATELLITE_TRAIL_BRIGHTNESS = 0.24;
|
||||||
const DIMMED_SATELLITE_POINT_OPACITY = 0.62;
|
const DIMMED_SATELLITE_POINT_OPACITY = 0.62;
|
||||||
@@ -325,6 +328,48 @@ export function updateBreathingPhase(deltaTime = 16) {
|
|||||||
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
|
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSatelliteIdleBreathingEnabled() {
|
||||||
|
return satelliteIdleBreathingEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSatelliteIdleBreathingEnabled(enabled) {
|
||||||
|
satelliteIdleBreathingEnabled = Boolean(enabled);
|
||||||
|
updateSatelliteIdleBreathingVisual(false);
|
||||||
|
return satelliteIdleBreathingEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSatelliteIdleBreathingVisual(isIdle = true) {
|
||||||
|
if (!satellitePoints || !satelliteBackdropPoints) return;
|
||||||
|
|
||||||
|
if (satellitePoints.material.uniforms?.opacity) {
|
||||||
|
satellitePoints.material.uniforms.opacity.value = 0.9;
|
||||||
|
} else {
|
||||||
|
satellitePoints.material.opacity = 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (satelliteBackdropPoints.material.uniforms?.opacity) {
|
||||||
|
satelliteBackdropPoints.material.uniforms.opacity.value = 0.42;
|
||||||
|
} else {
|
||||||
|
satelliteBackdropPoints.material.opacity = 0.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointAlphaAttr = satellitePoints.geometry.attributes.alpha;
|
||||||
|
const backdropAlphaAttr = satelliteBackdropPoints.geometry.attributes.alpha;
|
||||||
|
if (!pointAlphaAttr?.array || !backdropAlphaAttr?.array) return;
|
||||||
|
|
||||||
|
const drawCount = satellitePoints.geometry.drawRange?.count ?? satelliteCapacity;
|
||||||
|
const count = Math.min(drawCount, satelliteCapacity, satellitePositions.length);
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const alpha = shouldHideSatellitePoint(i)
|
||||||
|
? 0
|
||||||
|
: getSatelliteTwinkleAlpha(i, isIdle);
|
||||||
|
pointAlphaAttr.array[i] = alpha;
|
||||||
|
backdropAlphaAttr.array[i] = alpha;
|
||||||
|
}
|
||||||
|
pointAlphaAttr.needsUpdate = true;
|
||||||
|
backdropAlphaAttr.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
export function updateSatellitePointSize() {
|
export function updateSatellitePointSize() {
|
||||||
if (!satellitePoints || !cameraRef) return;
|
if (!satellitePoints || !cameraRef) return;
|
||||||
const camDist = cameraRef.position.length();
|
const camDist = cameraRef.position.length();
|
||||||
@@ -627,15 +672,57 @@ function getRequestedSatelliteLimit(limitOverride) {
|
|||||||
return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount;
|
return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSatellitePositionState() {
|
function hashSatelliteUnit(index, salt = 0) {
|
||||||
|
const value = Math.sin((index + 1) * 12.9898 + salt * 78.233) * 43758.5453;
|
||||||
|
return value - Math.floor(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSatelliteTwinkleState(index) {
|
||||||
|
return {
|
||||||
|
phase: hashSatelliteUnit(index, 1) * Math.PI * 2,
|
||||||
|
secondaryPhase: hashSatelliteUnit(index, 2) * Math.PI * 2,
|
||||||
|
speed: 0.62 + hashSatelliteUnit(index, 3) * 1.45,
|
||||||
|
floor: hashSatelliteUnit(index, 4) * 0.22,
|
||||||
|
intensity: 0.48 + hashSatelliteUnit(index, 5) * 0.52,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSatellitePositionState(index = 0) {
|
||||||
return {
|
return {
|
||||||
current: new THREE.Vector3(),
|
current: new THREE.Vector3(),
|
||||||
trail: [],
|
trail: [],
|
||||||
trailIndex: 0,
|
trailIndex: 0,
|
||||||
trailCount: 0,
|
trailCount: 0,
|
||||||
|
twinkle: createSatelliteTwinkleState(index),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSatelliteTwinkleAlpha(index, isIdle = true) {
|
||||||
|
if (!satelliteIdleBreathingEnabled || !isIdle) return 1;
|
||||||
|
|
||||||
|
const twinkle = satellitePositions[index]?.twinkle || createSatelliteTwinkleState(index);
|
||||||
|
const primaryPulse = getBreathingPulse(
|
||||||
|
breathingPhase * twinkle.speed + twinkle.phase,
|
||||||
|
);
|
||||||
|
const secondaryPulse = getBreathingPulse(
|
||||||
|
breathingPhase * twinkle.speed * SATELLITE_TWINKLE_SECONDARY_SPEED +
|
||||||
|
twinkle.secondaryPhase,
|
||||||
|
);
|
||||||
|
const mixedPulse = THREE.MathUtils.clamp(
|
||||||
|
primaryPulse * (1 - SATELLITE_TWINKLE_SECONDARY_WEIGHT) +
|
||||||
|
secondaryPulse * SATELLITE_TWINKLE_SECONDARY_WEIGHT,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const shapedPulse = twinkle.floor + Math.pow(mixedPulse, 1.8) * twinkle.intensity;
|
||||||
|
return THREE.MathUtils.clamp(
|
||||||
|
SATELLITE_CONFIG.dotOpacityMin +
|
||||||
|
shapedPulse * (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin),
|
||||||
|
SATELLITE_CONFIG.dotOpacityMin,
|
||||||
|
SATELLITE_CONFIG.dotOpacityMax,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function resetSatelliteTrailState() {
|
function resetSatelliteTrailState() {
|
||||||
satellitePositions.forEach((position) => {
|
satellitePositions.forEach((position) => {
|
||||||
position.trail = [];
|
position.trail = [];
|
||||||
@@ -794,7 +881,7 @@ function ensureSatelliteCapacity(count) {
|
|||||||
satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
|
satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
|
||||||
const previousState = previousSatellitePositions[index];
|
const previousState = previousSatellitePositions[index];
|
||||||
if (!previousState) {
|
if (!previousState) {
|
||||||
return createSatellitePositionState();
|
return createSatellitePositionState(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -802,6 +889,7 @@ function ensureSatelliteCapacity(count) {
|
|||||||
trail: previousState.trail.slice(),
|
trail: previousState.trail.slice(),
|
||||||
trailIndex: previousState.trailIndex,
|
trailIndex: previousState.trailIndex,
|
||||||
trailCount: previousState.trailCount,
|
trailCount: previousState.trailCount,
|
||||||
|
twinkle: previousState.twinkle || createSatelliteTwinkleState(index),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
satelliteCapacity = nextCapacity;
|
satelliteCapacity = nextCapacity;
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const DataSources = lazy(() => import('./pages/DataSources/DataSources'))
|
|||||||
const DataList = lazy(() => import('./pages/DataList/DataList'))
|
const DataList = lazy(() => import('./pages/DataList/DataList'))
|
||||||
const Earth = lazy(() => import('./pages/Earth/Earth'))
|
const Earth = lazy(() => import('./pages/Earth/Earth'))
|
||||||
const Settings = lazy(() => import('./pages/Settings/Settings'))
|
const Settings = lazy(() => import('./pages/Settings/Settings'))
|
||||||
|
const AISettings = lazy(() => import('./pages/AISettings/AISettings'))
|
||||||
const BGP = lazy(() => import('./pages/BGP/BGP'))
|
const BGP = lazy(() => import('./pages/BGP/BGP'))
|
||||||
const Playground = lazy(() => import('./pages/Playground/Playground'))
|
|
||||||
const Logs = lazy(() => import('./pages/Logs/Logs'))
|
const Logs = lazy(() => import('./pages/Logs/Logs'))
|
||||||
const Docs = lazy(() => import('./pages/Docs/Docs'))
|
const Docs = lazy(() => import('./pages/Docs/Docs'))
|
||||||
|
|
||||||
@@ -61,7 +61,8 @@ function App() {
|
|||||||
<Route path="/alerts/bgp" element={<BGPAlerts />} />
|
<Route path="/alerts/bgp" element={<BGPAlerts />} />
|
||||||
<Route path="/alerts/situational" element={<SituationalAlerts />} />
|
<Route path="/alerts/situational" element={<SituationalAlerts />} />
|
||||||
<Route path="/bgp" element={<BGP />} />
|
<Route path="/bgp" element={<BGP />} />
|
||||||
<Route path="/playground" element={<Playground />} />
|
<Route path="/ai" element={<AISettings />} />
|
||||||
|
<Route path="/playground" element={<Navigate to="/ai?tab=playground" replace />} />
|
||||||
<Route path="/logs" element={<Logs />} />
|
<Route path="/logs" element={<Logs />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { ReactNode, useMemo, useState } from 'react'
|
|||||||
import { Layout, Menu, Typography, Button, Space } from 'antd'
|
import { Layout, Menu, Typography, Button, Space } from 'antd'
|
||||||
import {
|
import {
|
||||||
AlertOutlined,
|
AlertOutlined,
|
||||||
|
ApiOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
DeploymentUnitOutlined,
|
DeploymentUnitOutlined,
|
||||||
RobotOutlined,
|
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
@@ -84,7 +84,7 @@ function AppLayout({ children }: AppLayoutProps) {
|
|||||||
icon: <ToolOutlined />,
|
icon: <ToolOutlined />,
|
||||||
label: '运维与配置',
|
label: '运维与配置',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/playground', icon: <RobotOutlined />, label: 'AI Playground' },
|
{ key: '/ai', icon: <ApiOutlined />, label: 'AI' },
|
||||||
...(isSuperAdmin ? [{ key: '/logs', icon: <FileTextOutlined />, label: '系统日志' }] : []),
|
...(isSuperAdmin ? [{ key: '/logs', icon: <FileTextOutlined />, label: '系统日志' }] : []),
|
||||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
|
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
|
||||||
|
|||||||
@@ -335,6 +335,11 @@ body {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playground-page--embedded {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.playground-page__grid {
|
.playground-page__grid {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -349,6 +354,10 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playground-page__body--embedded {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.playground-shell {
|
.playground-shell {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
824
frontend/src/pages/AISettings/AISettings.tsx
Normal file
824
frontend/src/pages/AISettings/AISettings.tsx
Normal file
@@ -0,0 +1,824 @@
|
|||||||
|
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
ApiOutlined,
|
||||||
|
EyeInvisibleOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
ToolOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
message,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||||
|
import { PlaygroundWorkspace } from '../Playground/Playground'
|
||||||
|
|
||||||
|
const { Title, Text } = Typography
|
||||||
|
const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200
|
||||||
|
const DEFAULT_PROVIDER_MAX_TOKENS = 4096
|
||||||
|
|
||||||
|
interface SecretStatus {
|
||||||
|
configured: boolean
|
||||||
|
preview: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AIProviderConfig {
|
||||||
|
provider: string
|
||||||
|
provider_api: string
|
||||||
|
base_url: string
|
||||||
|
model: string
|
||||||
|
api_key: SecretStatus
|
||||||
|
max_tokens: number
|
||||||
|
anthropic_version: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebSearchProviderConfig {
|
||||||
|
provider: string
|
||||||
|
base_url: string
|
||||||
|
api_key: SecretStatus
|
||||||
|
max_results: number
|
||||||
|
timeout_seconds: number
|
||||||
|
endpoint_path?: string
|
||||||
|
search_depth?: string
|
||||||
|
engine?: string
|
||||||
|
include_answer?: boolean
|
||||||
|
include_raw_content?: boolean
|
||||||
|
include_text?: boolean
|
||||||
|
categories?: string
|
||||||
|
engines?: string[]
|
||||||
|
search_path?: string
|
||||||
|
scrape_path?: string
|
||||||
|
scrape_formats?: string[]
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExternalIntegrations {
|
||||||
|
ai_provider: {
|
||||||
|
service_url: string
|
||||||
|
service_token: SecretStatus
|
||||||
|
default_provider: string
|
||||||
|
provider: string
|
||||||
|
provider_api: string
|
||||||
|
base_url: string
|
||||||
|
model: string
|
||||||
|
api_key: SecretStatus
|
||||||
|
providers: Record<string, AIProviderConfig>
|
||||||
|
max_tokens: number
|
||||||
|
anthropic_version: string
|
||||||
|
timeout_seconds: number
|
||||||
|
retry_attempts: number
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
barentswatch: {
|
||||||
|
endpoint: string
|
||||||
|
client_id: string
|
||||||
|
client_secret: SecretStatus
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
web_search: {
|
||||||
|
enabled: boolean
|
||||||
|
default_provider: string
|
||||||
|
provider: string
|
||||||
|
base_url: string
|
||||||
|
api_key: SecretStatus
|
||||||
|
providers: Record<string, WebSearchProviderConfig>
|
||||||
|
max_results: number
|
||||||
|
timeout_seconds: number
|
||||||
|
endpoint_path: string
|
||||||
|
search_depth: string
|
||||||
|
engine: string
|
||||||
|
include_answer: boolean
|
||||||
|
include_raw_content: boolean
|
||||||
|
include_text: boolean
|
||||||
|
categories: string
|
||||||
|
engines: string[]
|
||||||
|
search_path: string
|
||||||
|
scrape_path: string
|
||||||
|
scrape_formats: string[]
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AIProviderPreset {
|
||||||
|
provider: string
|
||||||
|
label: string
|
||||||
|
provider_api: string
|
||||||
|
base_url: string
|
||||||
|
model: string
|
||||||
|
models: string[]
|
||||||
|
api_key_env: string
|
||||||
|
source: string
|
||||||
|
refresh_error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebSearchPreset {
|
||||||
|
provider: string
|
||||||
|
label: string
|
||||||
|
api_key_env: string
|
||||||
|
base_url: string
|
||||||
|
endpoint_path?: string
|
||||||
|
search_path?: string
|
||||||
|
scrape_path?: string
|
||||||
|
max_results: number
|
||||||
|
timeout_seconds: number
|
||||||
|
search_depth?: string
|
||||||
|
engine?: string
|
||||||
|
include_answer?: boolean
|
||||||
|
include_raw_content?: boolean
|
||||||
|
include_text?: boolean
|
||||||
|
categories?: string
|
||||||
|
engines?: string[]
|
||||||
|
scrape_formats?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function AISettingsPanel({ loading, children }: { loading: boolean; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="settings-pane">
|
||||||
|
<Card className="settings-panel-card" loading={loading}>
|
||||||
|
<Scrollbar className="settings-panel-scroll">{children}</Scrollbar>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AISettings() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [integrations, setIntegrations] = useState<ExternalIntegrations | null>(null)
|
||||||
|
const [aiProviderPresets, setAiProviderPresets] = useState<AIProviderPreset[]>([])
|
||||||
|
const [webSearchPresets, setWebSearchPresets] = useState<WebSearchPreset[]>([])
|
||||||
|
const [refreshingAiPreset, setRefreshingAiPreset] = useState(false)
|
||||||
|
const [testingAiProviderConnection, setTestingAiProviderConnection] = useState(false)
|
||||||
|
const [testingWebSearchConnection, setTestingWebSearchConnection] = useState(false)
|
||||||
|
const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
|
||||||
|
const [revealedAiProviderSecrets, setRevealedAiProviderSecrets] = useState<Record<string, { api_key: string; service_token: string }>>({})
|
||||||
|
const [revealedWebSearchSecrets, setRevealedWebSearchSecrets] = useState<Record<string, { api_key: string }>>({})
|
||||||
|
const [aiProviderApiKeyRevealed, setAiProviderApiKeyRevealed] = useState(false)
|
||||||
|
const [serviceTokenRevealed, setServiceTokenRevealed] = useState(false)
|
||||||
|
const [webSearchApiKeyRevealed, setWebSearchApiKeyRevealed] = useState(false)
|
||||||
|
|
||||||
|
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], form)
|
||||||
|
const selectedWebSearchProvider = Form.useWatch(['web_search', 'provider'], form)
|
||||||
|
const selectedAiProviderSecret = selectedAiProvider
|
||||||
|
? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key
|
||||||
|
: integrations?.ai_provider.api_key
|
||||||
|
const selectedWebSearchSecret = selectedWebSearchProvider
|
||||||
|
? integrations?.web_search.providers?.[selectedWebSearchProvider]?.api_key || integrations?.web_search.api_key
|
||||||
|
: integrations?.web_search.api_key
|
||||||
|
const activeTab = useMemo(() => {
|
||||||
|
const tab = searchParams.get('tab') || 'providers'
|
||||||
|
return new Set(['providers', 'tools', 'playground']).has(tab) ? tab : 'providers'
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
const fetchAISettings = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const [settingsResponse, aiPresetsResponse, webPresetsResponse] = await Promise.all([
|
||||||
|
axios.get('/api/v1/settings'),
|
||||||
|
axios.get('/api/v1/settings/integrations/ai-provider/presets'),
|
||||||
|
axios.get('/api/v1/settings/integrations/web-search/presets'),
|
||||||
|
])
|
||||||
|
setIntegrations(settingsResponse.data.integrations || null)
|
||||||
|
setAiProviderPresets(aiPresetsResponse.data.data || [])
|
||||||
|
setWebSearchPresets(webPresetsResponse.data.data || [])
|
||||||
|
} catch {
|
||||||
|
message.error('获取 AI 配置失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchAISettings()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !integrations) return
|
||||||
|
form.setFieldsValue({
|
||||||
|
ai_provider: {
|
||||||
|
service_url: integrations.ai_provider.service_url,
|
||||||
|
service_token: integrations.ai_provider.service_token.configured
|
||||||
|
? integrations.ai_provider.service_token.preview
|
||||||
|
: '',
|
||||||
|
default_provider: integrations.ai_provider.default_provider || integrations.ai_provider.provider,
|
||||||
|
provider: integrations.ai_provider.provider,
|
||||||
|
provider_api: integrations.ai_provider.provider_api,
|
||||||
|
base_url: integrations.ai_provider.base_url,
|
||||||
|
model: integrations.ai_provider.model,
|
||||||
|
api_key: integrations.ai_provider.api_key.configured
|
||||||
|
? integrations.ai_provider.api_key.preview
|
||||||
|
: '',
|
||||||
|
max_tokens: integrations.ai_provider.max_tokens,
|
||||||
|
anthropic_version: integrations.ai_provider.anthropic_version,
|
||||||
|
timeout_seconds: integrations.ai_provider.timeout_seconds,
|
||||||
|
retry_attempts: integrations.ai_provider.retry_attempts,
|
||||||
|
},
|
||||||
|
web_search: {
|
||||||
|
enabled: integrations.web_search.enabled,
|
||||||
|
default_provider: integrations.web_search.default_provider || integrations.web_search.provider,
|
||||||
|
provider: integrations.web_search.provider,
|
||||||
|
base_url: integrations.web_search.base_url,
|
||||||
|
api_key: integrations.web_search.api_key.configured
|
||||||
|
? integrations.web_search.api_key.preview
|
||||||
|
: '',
|
||||||
|
max_results: integrations.web_search.max_results,
|
||||||
|
timeout_seconds: integrations.web_search.timeout_seconds,
|
||||||
|
endpoint_path: integrations.web_search.endpoint_path,
|
||||||
|
search_depth: integrations.web_search.search_depth,
|
||||||
|
engine: integrations.web_search.engine,
|
||||||
|
include_answer: integrations.web_search.include_answer,
|
||||||
|
include_raw_content: integrations.web_search.include_raw_content,
|
||||||
|
include_text: integrations.web_search.include_text,
|
||||||
|
categories: integrations.web_search.categories,
|
||||||
|
engines: integrations.web_search.engines,
|
||||||
|
search_path: integrations.web_search.search_path,
|
||||||
|
scrape_path: integrations.web_search.scrape_path,
|
||||||
|
scrape_formats: integrations.web_search.scrape_formats,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [form, integrations, loading])
|
||||||
|
|
||||||
|
const isSecretDraftUnchanged = (
|
||||||
|
nextSecret: string,
|
||||||
|
savedPreview?: string,
|
||||||
|
revealedSecret?: string,
|
||||||
|
) => (
|
||||||
|
!nextSecret ||
|
||||||
|
nextSecret === savedPreview ||
|
||||||
|
nextSecret === revealedSecret ||
|
||||||
|
nextSecret.startsWith('••••') ||
|
||||||
|
nextSecret.includes('*')
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildAiProviderDraftPayload = (values: any) => {
|
||||||
|
const selectedProvider = String(values.ai_provider?.provider || integrations?.ai_provider.provider || 'minimax')
|
||||||
|
const providerSecret = integrations?.ai_provider.providers?.[selectedProvider]?.api_key
|
||||||
|
|| integrations?.ai_provider.api_key
|
||||||
|
const revealedSecrets = revealedAiProviderSecrets[selectedProvider]
|
||||||
|
const nextApiKey = String(values.ai_provider?.api_key || '').trim()
|
||||||
|
const apiKeyUnchanged = isSecretDraftUnchanged(
|
||||||
|
nextApiKey,
|
||||||
|
providerSecret?.preview,
|
||||||
|
revealedSecrets?.api_key,
|
||||||
|
)
|
||||||
|
const nextServiceToken = String(values.ai_provider?.service_token || '').trim()
|
||||||
|
const serviceTokenUnchanged = isSecretDraftUnchanged(
|
||||||
|
nextServiceToken,
|
||||||
|
integrations?.ai_provider.service_token.preview,
|
||||||
|
revealedSecrets?.service_token,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...values.ai_provider,
|
||||||
|
default_provider: selectedProvider,
|
||||||
|
api_key: apiKeyUnchanged ? '' : nextApiKey,
|
||||||
|
service_token: serviceTokenUnchanged ? '' : nextServiceToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildWebSearchDraftPayload = (values: any) => {
|
||||||
|
const selectedProvider = String(values.web_search?.provider || integrations?.web_search.provider || 'tavily')
|
||||||
|
const providerSecret = integrations?.web_search.providers?.[selectedProvider]?.api_key
|
||||||
|
|| integrations?.web_search.api_key
|
||||||
|
const revealedSecrets = revealedWebSearchSecrets[selectedProvider]
|
||||||
|
const nextApiKey = String(values.web_search?.api_key || '').trim()
|
||||||
|
const apiKeyUnchanged = isSecretDraftUnchanged(
|
||||||
|
nextApiKey,
|
||||||
|
providerSecret?.preview,
|
||||||
|
revealedSecrets?.api_key,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...values.web_search,
|
||||||
|
default_provider: selectedProvider,
|
||||||
|
api_key: apiKeyUnchanged ? '' : nextApiKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildIntegrationsPayload = (values: any) => ({
|
||||||
|
ai_provider: buildAiProviderDraftPayload(values),
|
||||||
|
web_search: buildWebSearchDraftPayload(values),
|
||||||
|
barentswatch: {
|
||||||
|
endpoint: integrations?.barentswatch.endpoint || '',
|
||||||
|
client_id: integrations?.barentswatch.client_id || '',
|
||||||
|
client_secret: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveAISettings = async (values: any) => {
|
||||||
|
try {
|
||||||
|
setSaving(true)
|
||||||
|
const response = await axios.put('/api/v1/settings/integrations', buildIntegrationsPayload(values))
|
||||||
|
setIntegrations(response.data.integrations)
|
||||||
|
setFeedback({ type: 'success', message: 'AI 配置已保存' })
|
||||||
|
message.success('AI 配置已保存')
|
||||||
|
await fetchAISettings()
|
||||||
|
} catch {
|
||||||
|
setFeedback({ type: 'error', message: 'AI 配置保存失败' })
|
||||||
|
message.error('AI 配置保存失败')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const revealAiProviderSecrets = async (provider: string) => {
|
||||||
|
const cached = revealedAiProviderSecrets[provider]
|
||||||
|
if (cached) return cached
|
||||||
|
const response = await axios.get('/api/v1/settings/integrations/ai-provider/secrets', {
|
||||||
|
params: { provider },
|
||||||
|
})
|
||||||
|
const secrets = {
|
||||||
|
api_key: String(response.data.api_key || ''),
|
||||||
|
service_token: String(response.data.service_token || ''),
|
||||||
|
}
|
||||||
|
setRevealedAiProviderSecrets((prev) => ({ ...prev, [provider]: secrets }))
|
||||||
|
return secrets
|
||||||
|
}
|
||||||
|
|
||||||
|
const revealWebSearchSecrets = async (provider: string) => {
|
||||||
|
const cached = revealedWebSearchSecrets[provider]
|
||||||
|
if (cached) return cached
|
||||||
|
const response = await axios.get('/api/v1/settings/integrations/web-search/secrets', {
|
||||||
|
params: { provider },
|
||||||
|
})
|
||||||
|
const secrets = {
|
||||||
|
api_key: String(response.data.api_key || ''),
|
||||||
|
}
|
||||||
|
setRevealedWebSearchSecrets((prev) => ({ ...prev, [provider]: secrets }))
|
||||||
|
return secrets
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAiProviderApiKeyVisibleChange = async (visible: boolean) => {
|
||||||
|
const provider = String(form.getFieldValue(['ai_provider', 'provider']) || integrations?.ai_provider.provider || 'minimax')
|
||||||
|
const providerSecret = integrations?.ai_provider.providers?.[provider]?.api_key || integrations?.ai_provider.api_key
|
||||||
|
if (visible) {
|
||||||
|
try {
|
||||||
|
const secrets = await revealAiProviderSecrets(provider)
|
||||||
|
if (secrets.api_key) form.setFieldValue(['ai_provider', 'api_key'], secrets.api_key)
|
||||||
|
setAiProviderApiKeyRevealed(true)
|
||||||
|
} catch {
|
||||||
|
message.error('读取 LLM API Key 失败')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentValue = String(form.getFieldValue(['ai_provider', 'api_key']) || '')
|
||||||
|
const revealedValue = revealedAiProviderSecrets[provider]?.api_key
|
||||||
|
if (revealedValue && currentValue === revealedValue) {
|
||||||
|
form.setFieldValue(['ai_provider', 'api_key'], providerSecret?.preview || '')
|
||||||
|
}
|
||||||
|
setAiProviderApiKeyRevealed(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleServiceTokenVisibleChange = async (visible: boolean) => {
|
||||||
|
const provider = String(form.getFieldValue(['ai_provider', 'provider']) || integrations?.ai_provider.provider || 'minimax')
|
||||||
|
if (visible) {
|
||||||
|
try {
|
||||||
|
const secrets = await revealAiProviderSecrets(provider)
|
||||||
|
if (secrets.service_token) form.setFieldValue(['ai_provider', 'service_token'], secrets.service_token)
|
||||||
|
setServiceTokenRevealed(true)
|
||||||
|
} catch {
|
||||||
|
message.error('读取代理 Token 失败')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentValue = String(form.getFieldValue(['ai_provider', 'service_token']) || '')
|
||||||
|
const revealedValue = revealedAiProviderSecrets[provider]?.service_token
|
||||||
|
if (revealedValue && currentValue === revealedValue) {
|
||||||
|
form.setFieldValue(['ai_provider', 'service_token'], integrations?.ai_provider.service_token.preview || '')
|
||||||
|
}
|
||||||
|
setServiceTokenRevealed(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWebSearchApiKeyVisibleChange = async (visible: boolean) => {
|
||||||
|
const provider = String(form.getFieldValue(['web_search', 'provider']) || integrations?.web_search.provider || 'tavily')
|
||||||
|
const providerSecret = integrations?.web_search.providers?.[provider]?.api_key || integrations?.web_search.api_key
|
||||||
|
if (visible) {
|
||||||
|
try {
|
||||||
|
const secrets = await revealWebSearchSecrets(provider)
|
||||||
|
if (secrets.api_key) form.setFieldValue(['web_search', 'api_key'], secrets.api_key)
|
||||||
|
setWebSearchApiKeyRevealed(true)
|
||||||
|
} catch {
|
||||||
|
message.error('读取 WebSearch API Key 失败')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentValue = String(form.getFieldValue(['web_search', 'api_key']) || '')
|
||||||
|
const revealedValue = revealedWebSearchSecrets[provider]?.api_key
|
||||||
|
if (revealedValue && currentValue === revealedValue) {
|
||||||
|
form.setFieldValue(['web_search', 'api_key'], providerSecret?.preview || '')
|
||||||
|
}
|
||||||
|
setWebSearchApiKeyRevealed(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyAiProviderSelection = (provider: string, presetOverride?: AIProviderPreset) => {
|
||||||
|
const preset = presetOverride || aiProviderPresets.find((item) => item.provider === provider)
|
||||||
|
const savedProvider = integrations?.ai_provider.providers?.[provider]
|
||||||
|
const providerApi = savedProvider?.provider_api || preset?.provider_api || 'openai-completions'
|
||||||
|
const apiKey = savedProvider?.api_key.configured ? savedProvider.api_key.preview : ''
|
||||||
|
form.setFieldsValue({
|
||||||
|
ai_provider: {
|
||||||
|
default_provider: provider,
|
||||||
|
provider,
|
||||||
|
provider_api: providerApi,
|
||||||
|
base_url: savedProvider?.base_url || preset?.base_url || '',
|
||||||
|
model: savedProvider?.model || preset?.model || '',
|
||||||
|
api_key: apiKey,
|
||||||
|
max_tokens: savedProvider?.max_tokens || (providerApi === 'anthropic-messages'
|
||||||
|
? ANTHROPIC_MESSAGES_MAX_TOKENS
|
||||||
|
: DEFAULT_PROVIDER_MAX_TOKENS),
|
||||||
|
anthropic_version: savedProvider?.anthropic_version || '2023-06-01',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setAiProviderApiKeyRevealed(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyWebSearchProviderSelection = (provider: string, presetOverride?: WebSearchPreset) => {
|
||||||
|
const preset = presetOverride || webSearchPresets.find((item) => item.provider === provider)
|
||||||
|
const savedProvider = integrations?.web_search.providers?.[provider]
|
||||||
|
const apiKey = savedProvider?.api_key.configured ? savedProvider.api_key.preview : ''
|
||||||
|
form.setFieldsValue({
|
||||||
|
web_search: {
|
||||||
|
default_provider: provider,
|
||||||
|
provider,
|
||||||
|
enabled: integrations?.web_search.enabled ?? false,
|
||||||
|
base_url: savedProvider?.base_url || preset?.base_url || '',
|
||||||
|
api_key: apiKey,
|
||||||
|
max_results: savedProvider?.max_results || preset?.max_results || 5,
|
||||||
|
timeout_seconds: savedProvider?.timeout_seconds || preset?.timeout_seconds || 20,
|
||||||
|
endpoint_path: savedProvider?.endpoint_path || preset?.endpoint_path || '',
|
||||||
|
search_depth: savedProvider?.search_depth || preset?.search_depth || 'basic',
|
||||||
|
engine: savedProvider?.engine || preset?.engine || 'google',
|
||||||
|
include_answer: savedProvider?.include_answer ?? preset?.include_answer ?? false,
|
||||||
|
include_raw_content: savedProvider?.include_raw_content ?? preset?.include_raw_content ?? false,
|
||||||
|
include_text: savedProvider?.include_text ?? preset?.include_text ?? false,
|
||||||
|
categories: savedProvider?.categories || preset?.categories || 'general',
|
||||||
|
engines: savedProvider?.engines || preset?.engines || [],
|
||||||
|
search_path: savedProvider?.search_path || preset?.search_path || '',
|
||||||
|
scrape_path: savedProvider?.scrape_path || preset?.scrape_path || '',
|
||||||
|
scrape_formats: savedProvider?.scrape_formats || preset?.scrape_formats || ['markdown'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setWebSearchApiKeyRevealed(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshSelectedAiProviderPreset = async () => {
|
||||||
|
const provider = form.getFieldValue(['ai_provider', 'provider'])
|
||||||
|
if (!provider) return
|
||||||
|
try {
|
||||||
|
setRefreshingAiPreset(true)
|
||||||
|
const response = await axios.post(`/api/v1/settings/integrations/ai-provider/presets/${provider}/refresh`)
|
||||||
|
const preset = response.data.data as AIProviderPreset
|
||||||
|
setAiProviderPresets((prev) => {
|
||||||
|
const next = prev.filter((item) => item.provider !== preset.provider)
|
||||||
|
return [...next, preset].sort((a, b) => a.label.localeCompare(b.label))
|
||||||
|
})
|
||||||
|
applyAiProviderSelection(preset.provider, preset)
|
||||||
|
if (preset.refresh_error) {
|
||||||
|
message.warning('刷新失败,已使用本地 fallback 配置')
|
||||||
|
} else {
|
||||||
|
message.success('已刷新选中 Provider 的最新模型配置')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('刷新 Provider 配置失败')
|
||||||
|
} finally {
|
||||||
|
setRefreshingAiPreset(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testAiProviderConnection = async () => {
|
||||||
|
try {
|
||||||
|
setTestingAiProviderConnection(true)
|
||||||
|
const values = form.getFieldsValue(true)
|
||||||
|
const response = await axios.post(
|
||||||
|
'/api/v1/settings/integrations/ai-provider/connect',
|
||||||
|
buildAiProviderDraftPayload(values),
|
||||||
|
)
|
||||||
|
if (response.data.success || response.data.connected) {
|
||||||
|
if (response.data.integrations) {
|
||||||
|
setIntegrations(response.data.integrations)
|
||||||
|
await fetchAISettings()
|
||||||
|
}
|
||||||
|
setFeedback({ type: 'success', message: response.data.message || 'AI Provider 连接成功' })
|
||||||
|
message.success(response.data.message || 'AI Provider 连接成功')
|
||||||
|
} else {
|
||||||
|
setFeedback({ type: 'error', message: response.data.message || 'AI Provider 连接失败' })
|
||||||
|
message.error(response.data.message || 'AI Provider 连接失败')
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
||||||
|
const errorMessage = err.response?.data?.message || err.response?.data?.detail || 'AI Provider 连接失败'
|
||||||
|
setFeedback({ type: 'error', message: errorMessage })
|
||||||
|
message.error(errorMessage)
|
||||||
|
} finally {
|
||||||
|
setTestingAiProviderConnection(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testWebSearchConnection = async () => {
|
||||||
|
try {
|
||||||
|
setTestingWebSearchConnection(true)
|
||||||
|
const values = form.getFieldsValue(true)
|
||||||
|
const response = await axios.post(
|
||||||
|
'/api/v1/settings/integrations/web-search/connect',
|
||||||
|
buildWebSearchDraftPayload(values),
|
||||||
|
)
|
||||||
|
if (response.data.success || response.data.connected) {
|
||||||
|
setFeedback({ type: 'success', message: response.data.message || 'WebSearch 连接成功' })
|
||||||
|
message.success(response.data.message || 'WebSearch 连接成功')
|
||||||
|
} else {
|
||||||
|
setFeedback({ type: 'error', message: response.data.message || 'WebSearch 连接失败' })
|
||||||
|
message.error(response.data.message || 'WebSearch 连接失败')
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
||||||
|
const errorMessage = err.response?.data?.message || err.response?.data?.detail || 'WebSearch 连接失败'
|
||||||
|
setFeedback({ type: 'error', message: errorMessage })
|
||||||
|
message.error(errorMessage)
|
||||||
|
} finally {
|
||||||
|
setTestingWebSearchConnection(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{
|
||||||
|
key: 'providers',
|
||||||
|
label: '模型供应商',
|
||||||
|
forceRender: true,
|
||||||
|
children: (
|
||||||
|
<AISettingsPanel loading={loading}>
|
||||||
|
<Form form={form} layout="vertical" onFinish={saveAISettings} onValuesChange={() => setFeedback(null)}>
|
||||||
|
<Card size="small" title={<Space><ApiOutlined />LLM Provider</Space>}>
|
||||||
|
<Form.Item name={['ai_provider', 'provider']} label="Provider">
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={aiProviderPresets.map((preset) => ({
|
||||||
|
value: preset.provider,
|
||||||
|
label: `${preset.label} · ${preset.provider_api}`,
|
||||||
|
}))}
|
||||||
|
onChange={(value) => applyAiProviderSelection(value)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
|
||||||
|
<Form.Item name={['ai_provider', 'base_url']} label="LLM Base URL">
|
||||||
|
<Input placeholder="https://api.example.com/v1" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label=" ">
|
||||||
|
<Space>
|
||||||
|
<Button icon={<SyncOutlined />} loading={refreshingAiPreset} onClick={refreshSelectedAiProviderPreset}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button icon={<PlayCircleOutlined />} loading={testingAiProviderConnection} onClick={() => { void testAiProviderConnection() }}>
|
||||||
|
测试连接
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Form.Item name={['ai_provider', 'provider_api']} label="协议适配">
|
||||||
|
<Select>
|
||||||
|
<Select.Option value="openai-completions">OpenAI Chat Completions</Select.Option>
|
||||||
|
<Select.Option value="anthropic-messages">Anthropic Messages</Select.Option>
|
||||||
|
<Select.Option value="ollama-generate">Ollama Generate</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['ai_provider', 'model']} label="默认模型">
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={(
|
||||||
|
aiProviderPresets.find((preset) => preset.provider === selectedAiProvider)?.models || []
|
||||||
|
).map((model) => ({ value: model, label: model }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="LLM API Key">
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Space>
|
||||||
|
<Tag color={selectedAiProviderSecret?.configured ? 'green' : 'default'}>
|
||||||
|
{selectedAiProviderSecret?.configured ? '已配置' : '未配置'}
|
||||||
|
</Tag>
|
||||||
|
<Text type="secondary">保存时只会更新当前 Provider 的 key。</Text>
|
||||||
|
</Space>
|
||||||
|
<Form.Item name={['ai_provider', 'api_key']} noStyle>
|
||||||
|
<Input
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="输入新的 LLM API key"
|
||||||
|
suffix={(
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={aiProviderApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||||
|
onClick={() => { void handleAiProviderApiKeyVisibleChange(!aiProviderApiKeyRevealed) }}
|
||||||
|
aria-label={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||||
|
<Form.Item name={['ai_provider', 'max_tokens']} label="最大输出 Tokens">
|
||||||
|
<InputNumber min={1} max={200000} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['ai_provider', 'anthropic_version']} label="Anthropic Version">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['ai_provider', 'timeout_seconds']} label="超时(秒)">
|
||||||
|
<InputNumber min={5} max={600} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['ai_provider', 'retry_attempts']} label="重试次数">
|
||||||
|
<InputNumber min={1} max={10} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Card size="small" type="inner" title="本地 aiprovider 代理" style={{ marginTop: 8 }}>
|
||||||
|
<Form.Item name={['ai_provider', 'service_url']} label="代理地址">
|
||||||
|
<Input placeholder="http://localhost:8010" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="代理 Token">
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Space>
|
||||||
|
<Tag color={integrations?.ai_provider.service_token.configured ? 'green' : 'default'}>
|
||||||
|
{integrations?.ai_provider.service_token.configured ? '已配置' : '未配置'}
|
||||||
|
</Tag>
|
||||||
|
<Text type="secondary">用于 backend 调本地 aiprovider。</Text>
|
||||||
|
</Space>
|
||||||
|
<Form.Item name={['ai_provider', 'service_token']} noStyle>
|
||||||
|
<Input
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="输入新的代理 token"
|
||||||
|
suffix={(
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={serviceTokenRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||||
|
onClick={() => { void handleServiceTokenVisibleChange(!serviceTokenRevealed) }}
|
||||||
|
aria-label={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
</Card>
|
||||||
|
<Button type="primary" htmlType="submit" loading={saving} style={{ marginTop: 16 }}>
|
||||||
|
保存 AI 配置
|
||||||
|
</Button>
|
||||||
|
{feedback ? <Alert showIcon type={feedback.type} message={feedback.message} style={{ marginTop: 12 }} /> : null}
|
||||||
|
</Form>
|
||||||
|
</AISettingsPanel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tools',
|
||||||
|
label: '工具',
|
||||||
|
forceRender: true,
|
||||||
|
children: (
|
||||||
|
<AISettingsPanel loading={loading}>
|
||||||
|
<Form form={form} layout="vertical" onFinish={saveAISettings} onValuesChange={() => setFeedback(null)}>
|
||||||
|
<Card size="small" title={<Space><ToolOutlined />WebSearch 证据层</Space>}>
|
||||||
|
<Form.Item name={['web_search', 'enabled']} label="启用 WebSearch" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'provider']} label="WebSearch Provider">
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={webSearchPresets.map((preset) => ({
|
||||||
|
value: preset.provider,
|
||||||
|
label: preset.label,
|
||||||
|
}))}
|
||||||
|
onChange={(value) => applyWebSearchProviderSelection(value)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
|
||||||
|
<Form.Item name={['web_search', 'base_url']} label="API Base URL">
|
||||||
|
<Input placeholder="https://api.tavily.com" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label=" ">
|
||||||
|
<Button icon={<PlayCircleOutlined />} loading={testingWebSearchConnection} onClick={() => { void testWebSearchConnection() }}>
|
||||||
|
测试连接
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Form.Item label="WebSearch API Key">
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Space>
|
||||||
|
<Tag color={selectedWebSearchSecret?.configured ? 'green' : 'default'}>
|
||||||
|
{selectedWebSearchSecret?.configured ? '已配置' : '未配置'}
|
||||||
|
</Tag>
|
||||||
|
<Text type="secondary">保存时只会更新当前 WebSearch Provider 的 key。</Text>
|
||||||
|
</Space>
|
||||||
|
<Form.Item name={['web_search', 'api_key']} noStyle>
|
||||||
|
<Input
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="输入新的 WebSearch API key;SearXNG 可留空"
|
||||||
|
suffix={(
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={webSearchApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||||
|
onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }}
|
||||||
|
aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||||
|
<Form.Item name={['web_search', 'max_results']} label="最大结果数">
|
||||||
|
<InputNumber min={1} max={20} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'timeout_seconds']} label="超时(秒)">
|
||||||
|
<InputNumber min={3} max={120} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Card size="small" type="inner" title="高级选项" style={{ marginTop: 8 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||||
|
<Form.Item name={['web_search', 'endpoint_path']} label="Endpoint Path">
|
||||||
|
<Input placeholder="/search" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'search_depth']} label="Search Depth">
|
||||||
|
<Input placeholder="basic" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'engine']} label="SerpAPI Engine">
|
||||||
|
<Input placeholder="google" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'categories']} label="SearXNG Categories">
|
||||||
|
<Input placeholder="general" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'search_path']} label="Firecrawl Search Path">
|
||||||
|
<Input placeholder="/v2/search" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'scrape_path']} label="Firecrawl Scrape Path">
|
||||||
|
<Input placeholder="/v2/scrape" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Space wrap>
|
||||||
|
<Form.Item name={['web_search', 'include_answer']} valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||||
|
<Checkbox>包含 Answer</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'include_raw_content']} valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||||
|
<Checkbox>包含 Raw Content</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'include_text']} valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||||
|
<Checkbox>包含 Result Text</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Card>
|
||||||
|
<Button type="primary" htmlType="submit" loading={saving} style={{ marginTop: 16 }}>
|
||||||
|
保存 Tool 配置
|
||||||
|
</Button>
|
||||||
|
{feedback ? <Alert showIcon type={feedback.type} message={feedback.message} style={{ marginTop: 12 }} /> : null}
|
||||||
|
</Form>
|
||||||
|
</AISettingsPanel>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'playground',
|
||||||
|
label: '测试台',
|
||||||
|
forceRender: true,
|
||||||
|
children: <PlaygroundWorkspace embedded />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="page-shell settings-shell">
|
||||||
|
<div className="page-shell__header">
|
||||||
|
<div>
|
||||||
|
<Title level={3} style={{ marginBottom: 4 }}>AI</Title>
|
||||||
|
<Text type="secondary">管理 LLM Provider、模型默认值和后端工具能力。</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="page-shell__body settings-tabs-shell">
|
||||||
|
<Tabs
|
||||||
|
className="settings-tabs"
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={(tabKey) => setSearchParams(tabKey === 'providers' ? {} : { tab: tabKey }, { replace: true })}
|
||||||
|
items={tabItems}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -216,7 +216,7 @@ function toPlaygroundMessage(item: PlaygroundApiMessage): PlaygroundMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Playground() {
|
export function PlaygroundWorkspace({ embedded = false }: { embedded?: boolean } = {}) {
|
||||||
const [messageApi, contextHolder] = message.useMessage()
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
const { token } = useAuthStore()
|
const { token } = useAuthStore()
|
||||||
const [statusLoading, setStatusLoading] = useState(false)
|
const [statusLoading, setStatusLoading] = useState(false)
|
||||||
@@ -634,7 +634,7 @@ function Playground() {
|
|||||||
type="warning"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
message="AI Provider 尚未配置完整"
|
message="AI Provider 尚未配置完整"
|
||||||
description={<Link to="/settings?tab=ai">前往 AI 配置</Link>}
|
description={<Link to="/ai">前往 AI 配置</Link>}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -643,7 +643,7 @@ function Playground() {
|
|||||||
type="warning"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
message="尚未获取到 AI Provider 状态"
|
message="尚未获取到 AI Provider 状态"
|
||||||
description={<Link to="/settings?tab=ai">前往 AI 配置</Link>}
|
description={<Link to="/ai">前往 AI 配置</Link>}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Spin>
|
</Spin>
|
||||||
@@ -683,20 +683,22 @@ function Playground() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<AppLayout>
|
<>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<div className="page-shell playground-page">
|
<div className={embedded ? 'playground-page playground-page--embedded' : 'page-shell playground-page'}>
|
||||||
|
{!embedded ? (
|
||||||
<div className="page-shell__header playground-page__header">
|
<div className="page-shell__header playground-page__header">
|
||||||
<div>
|
<div>
|
||||||
<Title level={3} style={{ marginBottom: 4 }}>AI Playground</Title>
|
<Title level={3} style={{ marginBottom: 4 }}>AI 测试台</Title>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
这里现在是一个真实链路的 AI Chatbox。页面负责调试输入与展示,模型请求仍统一经由主后端转发到 AI Provider。
|
这里现在是一个真实链路的 AI Chatbox。页面负责调试输入与展示,模型请求仍统一经由主后端转发到 AI Provider。
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="page-shell__body playground-page__body">
|
<div className={embedded ? 'playground-page__body playground-page__body--embedded' : 'page-shell__body playground-page__body'}>
|
||||||
<div className="playground-shell">
|
<div className="playground-shell">
|
||||||
<div className="playground-shell__sidebar">
|
<div className="playground-shell__sidebar">
|
||||||
{providerStatusPanel}
|
{providerStatusPanel}
|
||||||
@@ -1096,8 +1098,22 @@ function Playground() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
{content}
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Playground() {
|
||||||
|
return <PlaygroundWorkspace />
|
||||||
|
}
|
||||||
|
|
||||||
export default Playground
|
export default Playground
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
|||||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||||
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200
|
const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200
|
||||||
@@ -145,6 +145,26 @@ interface AIProviderConfig {
|
|||||||
source?: string
|
source?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WebSearchProviderConfig {
|
||||||
|
provider: string
|
||||||
|
base_url: string
|
||||||
|
api_key: SecretStatus
|
||||||
|
max_results: number
|
||||||
|
timeout_seconds: number
|
||||||
|
endpoint_path?: string
|
||||||
|
search_depth?: string
|
||||||
|
engine?: string
|
||||||
|
include_answer?: boolean
|
||||||
|
include_raw_content?: boolean
|
||||||
|
include_text?: boolean
|
||||||
|
categories?: string
|
||||||
|
engines?: string[]
|
||||||
|
search_path?: string
|
||||||
|
scrape_path?: string
|
||||||
|
scrape_formats?: string[]
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ExternalIntegrations {
|
interface ExternalIntegrations {
|
||||||
ai_provider: {
|
ai_provider: {
|
||||||
service_url: string
|
service_url: string
|
||||||
@@ -168,6 +188,28 @@ interface ExternalIntegrations {
|
|||||||
client_secret: SecretStatus
|
client_secret: SecretStatus
|
||||||
source: string
|
source: string
|
||||||
}
|
}
|
||||||
|
web_search: {
|
||||||
|
enabled: boolean
|
||||||
|
default_provider: string
|
||||||
|
provider: string
|
||||||
|
base_url: string
|
||||||
|
api_key: SecretStatus
|
||||||
|
providers: Record<string, WebSearchProviderConfig>
|
||||||
|
max_results: number
|
||||||
|
timeout_seconds: number
|
||||||
|
endpoint_path: string
|
||||||
|
search_depth: string
|
||||||
|
engine: string
|
||||||
|
include_answer: boolean
|
||||||
|
include_raw_content: boolean
|
||||||
|
include_text: boolean
|
||||||
|
categories: string
|
||||||
|
engines: string[]
|
||||||
|
search_path: string
|
||||||
|
scrape_path: string
|
||||||
|
scrape_formats: string[]
|
||||||
|
source: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AIProviderPreset {
|
interface AIProviderPreset {
|
||||||
@@ -182,12 +224,35 @@ interface AIProviderPreset {
|
|||||||
refresh_error?: string
|
refresh_error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WebSearchPreset {
|
||||||
|
provider: string
|
||||||
|
label: string
|
||||||
|
api_key_env: string
|
||||||
|
base_url: string
|
||||||
|
endpoint_path?: string
|
||||||
|
search_path?: string
|
||||||
|
scrape_path?: string
|
||||||
|
max_results: number
|
||||||
|
timeout_seconds: number
|
||||||
|
search_depth?: string
|
||||||
|
engine?: string
|
||||||
|
include_answer?: boolean
|
||||||
|
include_raw_content?: boolean
|
||||||
|
include_text?: boolean
|
||||||
|
categories?: string
|
||||||
|
engines?: string[]
|
||||||
|
scrape_formats?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
interface CredentialGuide {
|
interface CredentialGuide {
|
||||||
provider: string
|
provider: string
|
||||||
title: string
|
title: string
|
||||||
markdown: string
|
markdown: string
|
||||||
prompt: string
|
prompt: string
|
||||||
source: string
|
source: string
|
||||||
|
sources?: Array<{ title?: string; url?: string; snippet?: string }>
|
||||||
|
verification_status?: string
|
||||||
|
verification_error?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CollectorConfigOption {
|
interface CollectorConfigOption {
|
||||||
@@ -326,6 +391,7 @@ function SettingsPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Settings() {
|
function Settings() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const requestedTab = searchParams.get('tab') || 'display'
|
const requestedTab = searchParams.get('tab') || 'display'
|
||||||
const requestedCollector = searchParams.get('collector') || ''
|
const requestedCollector = searchParams.get('collector') || ''
|
||||||
@@ -339,6 +405,7 @@ function Settings() {
|
|||||||
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
|
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
|
||||||
const [integrations, setIntegrations] = useState<ExternalIntegrations | null>(null)
|
const [integrations, setIntegrations] = useState<ExternalIntegrations | null>(null)
|
||||||
const [aiProviderPresets, setAiProviderPresets] = useState<AIProviderPreset[]>([])
|
const [aiProviderPresets, setAiProviderPresets] = useState<AIProviderPreset[]>([])
|
||||||
|
const [webSearchPresets, setWebSearchPresets] = useState<WebSearchPreset[]>([])
|
||||||
const [collectorConfigs, setCollectorConfigs] = useState<CollectorConfigOption[]>([])
|
const [collectorConfigs, setCollectorConfigs] = useState<CollectorConfigOption[]>([])
|
||||||
const [selectedCollectorSource, setSelectedCollectorSource] = useState<string>('barentswatch_vessels')
|
const [selectedCollectorSource, setSelectedCollectorSource] = useState<string>('barentswatch_vessels')
|
||||||
const [savingCollectorConfig, setSavingCollectorConfig] = useState(false)
|
const [savingCollectorConfig, setSavingCollectorConfig] = useState(false)
|
||||||
@@ -347,9 +414,13 @@ function Settings() {
|
|||||||
const [savingTvSettings, setSavingTvSettings] = useState(false)
|
const [savingTvSettings, setSavingTvSettings] = useState(false)
|
||||||
const [savingIntegrations, setSavingIntegrations] = useState(false)
|
const [savingIntegrations, setSavingIntegrations] = useState(false)
|
||||||
const [aiProviderSaveFeedback, setAiProviderSaveFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
|
const [aiProviderSaveFeedback, setAiProviderSaveFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
|
||||||
|
const [webSearchSaveFeedback, setWebSearchSaveFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
|
||||||
const [testingAiProviderConnection, setTestingAiProviderConnection] = useState(false)
|
const [testingAiProviderConnection, setTestingAiProviderConnection] = useState(false)
|
||||||
|
const [testingWebSearchConnection, setTestingWebSearchConnection] = useState(false)
|
||||||
const [revealedAiProviderSecrets, setRevealedAiProviderSecrets] = useState<Record<string, { api_key: string; service_token: string }>>({})
|
const [revealedAiProviderSecrets, setRevealedAiProviderSecrets] = useState<Record<string, { api_key: string; service_token: string }>>({})
|
||||||
|
const [revealedWebSearchSecrets, setRevealedWebSearchSecrets] = useState<Record<string, { api_key: string }>>({})
|
||||||
const [aiProviderApiKeyRevealed, setAiProviderApiKeyRevealed] = useState(false)
|
const [aiProviderApiKeyRevealed, setAiProviderApiKeyRevealed] = useState(false)
|
||||||
|
const [webSearchApiKeyRevealed, setWebSearchApiKeyRevealed] = useState(false)
|
||||||
const [serviceTokenRevealed, setServiceTokenRevealed] = useState(false)
|
const [serviceTokenRevealed, setServiceTokenRevealed] = useState(false)
|
||||||
const [testingCredentialProvider, setTestingCredentialProvider] = useState<string | null>(null)
|
const [testingCredentialProvider, setTestingCredentialProvider] = useState<string | null>(null)
|
||||||
const [credentialGuide, setCredentialGuide] = useState<CredentialGuide | null>(null)
|
const [credentialGuide, setCredentialGuide] = useState<CredentialGuide | null>(null)
|
||||||
@@ -369,9 +440,13 @@ function Settings() {
|
|||||||
const [customSourceForm] = Form.useForm()
|
const [customSourceForm] = Form.useForm()
|
||||||
const [tvEditForm] = Form.useForm<TVStreamSource>()
|
const [tvEditForm] = Form.useForm<TVStreamSource>()
|
||||||
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], integrationForm)
|
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], integrationForm)
|
||||||
|
const selectedWebSearchProvider = Form.useWatch(['web_search', 'provider'], integrationForm)
|
||||||
const selectedAiProviderSecret = selectedAiProvider
|
const selectedAiProviderSecret = selectedAiProvider
|
||||||
? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key
|
? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key
|
||||||
: integrations?.ai_provider.api_key
|
: integrations?.ai_provider.api_key
|
||||||
|
const selectedWebSearchSecret = selectedWebSearchProvider
|
||||||
|
? integrations?.web_search.providers?.[selectedWebSearchProvider]?.api_key || integrations?.web_search.api_key
|
||||||
|
: integrations?.web_search.api_key
|
||||||
const customCollectors: CollectorSettings[] = useMemo(() => customSourceConfigs.map((config) => ({
|
const customCollectors: CollectorSettings[] = useMemo(() => customSourceConfigs.map((config) => ({
|
||||||
id: -(config.config_id || 0),
|
id: -(config.config_id || 0),
|
||||||
name: config.name,
|
name: config.name,
|
||||||
@@ -405,7 +480,6 @@ function Settings() {
|
|||||||
'notifications',
|
'notifications',
|
||||||
'security',
|
'security',
|
||||||
'tv',
|
'tv',
|
||||||
'ai',
|
|
||||||
'collector_credentials',
|
'collector_credentials',
|
||||||
'collectors',
|
'collectors',
|
||||||
])
|
])
|
||||||
@@ -428,9 +502,10 @@ function Settings() {
|
|||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const [response, presetsResponse, collectorConfigsResponse, customConfigsResponse] = await Promise.all([
|
const [response, presetsResponse, webSearchPresetsResponse, collectorConfigsResponse, customConfigsResponse] = await Promise.all([
|
||||||
axios.get('/api/v1/settings'),
|
axios.get('/api/v1/settings'),
|
||||||
axios.get('/api/v1/settings/integrations/ai-provider/presets'),
|
axios.get('/api/v1/settings/integrations/ai-provider/presets'),
|
||||||
|
axios.get('/api/v1/settings/integrations/web-search/presets'),
|
||||||
axios.get('/api/v1/datasources/configs/all'),
|
axios.get('/api/v1/datasources/configs/all'),
|
||||||
axios.get('/api/v1/datasources/configs'),
|
axios.get('/api/v1/datasources/configs'),
|
||||||
])
|
])
|
||||||
@@ -441,6 +516,7 @@ function Settings() {
|
|||||||
setIntegrations(response.data.integrations || null)
|
setIntegrations(response.data.integrations || null)
|
||||||
setCollectors(response.data.collectors || [])
|
setCollectors(response.data.collectors || [])
|
||||||
setAiProviderPresets(presetsResponse.data.data || [])
|
setAiProviderPresets(presetsResponse.data.data || [])
|
||||||
|
setWebSearchPresets(webSearchPresetsResponse.data.data || [])
|
||||||
const builtinConfigs = collectorConfigsResponse.data.data || []
|
const builtinConfigs = collectorConfigsResponse.data.data || []
|
||||||
setCollectorConfigs(builtinConfigs)
|
setCollectorConfigs(builtinConfigs)
|
||||||
const builtinNames = new Set((response.data.collectors || []).map((collector: CollectorSettings) => collector.source))
|
const builtinNames = new Set((response.data.collectors || []).map((collector: CollectorSettings) => collector.source))
|
||||||
@@ -459,6 +535,12 @@ function Settings() {
|
|||||||
fetchSettings()
|
fetchSettings()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requestedTab === 'ai') {
|
||||||
|
navigate('/ai', { replace: true })
|
||||||
|
}
|
||||||
|
}, [navigate, requestedTab])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && systemSettings) {
|
if (!loading && systemSettings) {
|
||||||
systemForm.setFieldsValue(systemSettings)
|
systemForm.setFieldsValue(systemSettings)
|
||||||
@@ -505,6 +587,28 @@ function Settings() {
|
|||||||
? integrations.barentswatch.client_secret.preview
|
? integrations.barentswatch.client_secret.preview
|
||||||
: '',
|
: '',
|
||||||
},
|
},
|
||||||
|
web_search: {
|
||||||
|
enabled: integrations.web_search.enabled,
|
||||||
|
default_provider: integrations.web_search.default_provider || integrations.web_search.provider,
|
||||||
|
provider: integrations.web_search.provider,
|
||||||
|
base_url: integrations.web_search.base_url,
|
||||||
|
api_key: integrations.web_search.api_key.configured
|
||||||
|
? integrations.web_search.api_key.preview
|
||||||
|
: '',
|
||||||
|
max_results: integrations.web_search.max_results,
|
||||||
|
timeout_seconds: integrations.web_search.timeout_seconds,
|
||||||
|
endpoint_path: integrations.web_search.endpoint_path,
|
||||||
|
search_depth: integrations.web_search.search_depth,
|
||||||
|
engine: integrations.web_search.engine,
|
||||||
|
include_answer: integrations.web_search.include_answer,
|
||||||
|
include_raw_content: integrations.web_search.include_raw_content,
|
||||||
|
include_text: integrations.web_search.include_text,
|
||||||
|
categories: integrations.web_search.categories,
|
||||||
|
engines: integrations.web_search.engines,
|
||||||
|
search_path: integrations.web_search.search_path,
|
||||||
|
scrape_path: integrations.web_search.scrape_path,
|
||||||
|
scrape_formats: integrations.web_search.scrape_formats,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}, [integrationForm, integrations, loading])
|
}, [integrationForm, integrations, loading])
|
||||||
|
|
||||||
@@ -635,9 +739,11 @@ function Settings() {
|
|||||||
try {
|
try {
|
||||||
setSavingIntegrations(true)
|
setSavingIntegrations(true)
|
||||||
const aiProviderPayload = buildAiProviderDraftPayload(values)
|
const aiProviderPayload = buildAiProviderDraftPayload(values)
|
||||||
|
const webSearchPayload = buildWebSearchDraftPayload(values)
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
ai_provider: aiProviderPayload,
|
ai_provider: aiProviderPayload,
|
||||||
|
web_search: webSearchPayload,
|
||||||
barentswatch: {
|
barentswatch: {
|
||||||
...values.barentswatch,
|
...values.barentswatch,
|
||||||
client_secret:
|
client_secret:
|
||||||
@@ -649,33 +755,47 @@ function Settings() {
|
|||||||
const response = await axios.put('/api/v1/settings/integrations', payload)
|
const response = await axios.put('/api/v1/settings/integrations', payload)
|
||||||
setIntegrations(response.data.integrations)
|
setIntegrations(response.data.integrations)
|
||||||
setAiProviderSaveFeedback({ type: 'success', message: 'AI 配置已保存为全局默认配置' })
|
setAiProviderSaveFeedback({ type: 'success', message: 'AI 配置已保存为全局默认配置' })
|
||||||
|
setWebSearchSaveFeedback({ type: 'success', message: 'WebSearch 配置已保存' })
|
||||||
message.success('外部集成配置已保存')
|
message.success('外部集成配置已保存')
|
||||||
await fetchSettings()
|
await fetchSettings()
|
||||||
} catch {
|
} catch {
|
||||||
setAiProviderSaveFeedback({ type: 'error', message: 'AI 配置保存失败' })
|
setAiProviderSaveFeedback({ type: 'error', message: 'AI 配置保存失败' })
|
||||||
|
setWebSearchSaveFeedback({ type: 'error', message: 'WebSearch 配置保存失败' })
|
||||||
message.error('外部集成配置保存失败')
|
message.error('外部集成配置保存失败')
|
||||||
} finally {
|
} finally {
|
||||||
setSavingIntegrations(false)
|
setSavingIntegrations(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSecretDraftUnchanged = (
|
||||||
|
nextSecret: string,
|
||||||
|
savedPreview?: string,
|
||||||
|
revealedSecret?: string,
|
||||||
|
) => (
|
||||||
|
!nextSecret ||
|
||||||
|
nextSecret === savedPreview ||
|
||||||
|
nextSecret === revealedSecret ||
|
||||||
|
nextSecret.startsWith('••••') ||
|
||||||
|
nextSecret.includes('*')
|
||||||
|
)
|
||||||
|
|
||||||
const buildAiProviderDraftPayload = (values: any) => {
|
const buildAiProviderDraftPayload = (values: any) => {
|
||||||
const selectedProvider = String(values.ai_provider?.provider || integrations?.ai_provider.provider || 'minimax')
|
const selectedProvider = String(values.ai_provider?.provider || integrations?.ai_provider.provider || 'minimax')
|
||||||
const providerSecret = integrations?.ai_provider.providers?.[selectedProvider]?.api_key
|
const providerSecret = integrations?.ai_provider.providers?.[selectedProvider]?.api_key
|
||||||
|| integrations?.ai_provider.api_key
|
|| integrations?.ai_provider.api_key
|
||||||
const revealedSecrets = revealedAiProviderSecrets[selectedProvider]
|
const revealedSecrets = revealedAiProviderSecrets[selectedProvider]
|
||||||
const nextApiKey = String(values.ai_provider?.api_key || '').trim()
|
const nextApiKey = String(values.ai_provider?.api_key || '').trim()
|
||||||
const apiKeyUnchanged = !nextApiKey
|
const apiKeyUnchanged = isSecretDraftUnchanged(
|
||||||
|| nextApiKey === providerSecret?.preview
|
nextApiKey,
|
||||||
|| nextApiKey === revealedSecrets?.api_key
|
providerSecret?.preview,
|
||||||
|| nextApiKey.startsWith('••••')
|
revealedSecrets?.api_key,
|
||||||
|| nextApiKey.includes('*')
|
)
|
||||||
const nextServiceToken = String(values.ai_provider?.service_token || '').trim()
|
const nextServiceToken = String(values.ai_provider?.service_token || '').trim()
|
||||||
const serviceTokenUnchanged = !nextServiceToken
|
const serviceTokenUnchanged = isSecretDraftUnchanged(
|
||||||
|| nextServiceToken === integrations?.ai_provider.service_token.preview
|
nextServiceToken,
|
||||||
|| nextServiceToken === revealedSecrets?.service_token
|
integrations?.ai_provider.service_token.preview,
|
||||||
|| nextServiceToken.startsWith('••••')
|
revealedSecrets?.service_token,
|
||||||
|| nextServiceToken.includes('*')
|
)
|
||||||
return {
|
return {
|
||||||
...values.ai_provider,
|
...values.ai_provider,
|
||||||
default_provider: selectedProvider,
|
default_provider: selectedProvider,
|
||||||
@@ -684,6 +804,24 @@ function Settings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildWebSearchDraftPayload = (values: any) => {
|
||||||
|
const selectedProvider = String(values.web_search?.provider || integrations?.web_search.provider || 'tavily')
|
||||||
|
const providerSecret = integrations?.web_search.providers?.[selectedProvider]?.api_key
|
||||||
|
|| integrations?.web_search.api_key
|
||||||
|
const revealedSecrets = revealedWebSearchSecrets[selectedProvider]
|
||||||
|
const nextApiKey = String(values.web_search?.api_key || '').trim()
|
||||||
|
const apiKeyUnchanged = isSecretDraftUnchanged(
|
||||||
|
nextApiKey,
|
||||||
|
providerSecret?.preview,
|
||||||
|
revealedSecrets?.api_key,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...values.web_search,
|
||||||
|
default_provider: selectedProvider,
|
||||||
|
api_key: apiKeyUnchanged ? '' : nextApiKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const revealAiProviderSecrets = async (provider: string) => {
|
const revealAiProviderSecrets = async (provider: string) => {
|
||||||
const cached = revealedAiProviderSecrets[provider]
|
const cached = revealedAiProviderSecrets[provider]
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
@@ -743,6 +881,67 @@ function Settings() {
|
|||||||
setServiceTokenRevealed(false)
|
setServiceTokenRevealed(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const revealWebSearchSecrets = async (provider: string) => {
|
||||||
|
const cached = revealedWebSearchSecrets[provider]
|
||||||
|
if (cached) return cached
|
||||||
|
const response = await axios.get('/api/v1/settings/integrations/web-search/secrets', {
|
||||||
|
params: { provider },
|
||||||
|
})
|
||||||
|
const secrets = {
|
||||||
|
api_key: String(response.data.api_key || ''),
|
||||||
|
}
|
||||||
|
setRevealedWebSearchSecrets((prev) => ({ ...prev, [provider]: secrets }))
|
||||||
|
return secrets
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWebSearchApiKeyVisibleChange = async (visible: boolean) => {
|
||||||
|
const provider = String(integrationForm.getFieldValue(['web_search', 'provider']) || integrations?.web_search.provider || 'tavily')
|
||||||
|
const providerSecret = integrations?.web_search.providers?.[provider]?.api_key || integrations?.web_search.api_key
|
||||||
|
if (visible) {
|
||||||
|
try {
|
||||||
|
const secrets = await revealWebSearchSecrets(provider)
|
||||||
|
if (secrets.api_key) {
|
||||||
|
integrationForm.setFieldValue(['web_search', 'api_key'], secrets.api_key)
|
||||||
|
}
|
||||||
|
setWebSearchApiKeyRevealed(true)
|
||||||
|
} catch {
|
||||||
|
message.error('读取 WebSearch API Key 失败')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentValue = String(integrationForm.getFieldValue(['web_search', 'api_key']) || '')
|
||||||
|
const revealedValue = revealedWebSearchSecrets[provider]?.api_key
|
||||||
|
if (revealedValue && currentValue === revealedValue) {
|
||||||
|
integrationForm.setFieldValue(['web_search', 'api_key'], providerSecret?.preview || '')
|
||||||
|
}
|
||||||
|
setWebSearchApiKeyRevealed(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const testWebSearchConnection = async () => {
|
||||||
|
try {
|
||||||
|
setTestingWebSearchConnection(true)
|
||||||
|
const values = integrationForm.getFieldsValue(true)
|
||||||
|
const response = await axios.post(
|
||||||
|
'/api/v1/settings/integrations/web-search/connect',
|
||||||
|
buildWebSearchDraftPayload(values),
|
||||||
|
)
|
||||||
|
if (response.data.success || response.data.connected) {
|
||||||
|
setWebSearchSaveFeedback({ type: 'success', message: response.data.message || 'WebSearch 连接成功' })
|
||||||
|
message.success(response.data.message || 'WebSearch 连接成功')
|
||||||
|
} else {
|
||||||
|
setWebSearchSaveFeedback({ type: 'error', message: response.data.message || 'WebSearch 连接失败' })
|
||||||
|
message.error(response.data.message || 'WebSearch 连接失败')
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
||||||
|
const errorMessage = err.response?.data?.message || err.response?.data?.detail || 'WebSearch 连接失败'
|
||||||
|
setWebSearchSaveFeedback({ type: 'error', message: errorMessage })
|
||||||
|
message.error(errorMessage)
|
||||||
|
} finally {
|
||||||
|
setTestingWebSearchConnection(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const testAiProviderConnection = async () => {
|
const testAiProviderConnection = async () => {
|
||||||
try {
|
try {
|
||||||
setTestingAiProviderConnection(true)
|
setTestingAiProviderConnection(true)
|
||||||
@@ -1276,6 +1475,35 @@ function Settings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyWebSearchProviderSelection = (provider: string, presetOverride?: WebSearchPreset) => {
|
||||||
|
const preset = presetOverride || webSearchPresets.find((item) => item.provider === provider)
|
||||||
|
const savedProvider = integrations?.web_search.providers?.[provider]
|
||||||
|
const apiKey = savedProvider?.api_key.configured ? savedProvider.api_key.preview : ''
|
||||||
|
integrationForm.setFieldsValue({
|
||||||
|
web_search: {
|
||||||
|
default_provider: provider,
|
||||||
|
provider,
|
||||||
|
enabled: integrations?.web_search.enabled ?? false,
|
||||||
|
base_url: savedProvider?.base_url || preset?.base_url || '',
|
||||||
|
api_key: apiKey,
|
||||||
|
max_results: savedProvider?.max_results || preset?.max_results || 5,
|
||||||
|
timeout_seconds: savedProvider?.timeout_seconds || preset?.timeout_seconds || 20,
|
||||||
|
endpoint_path: savedProvider?.endpoint_path || preset?.endpoint_path || '',
|
||||||
|
search_depth: savedProvider?.search_depth || preset?.search_depth || 'basic',
|
||||||
|
engine: savedProvider?.engine || preset?.engine || 'google',
|
||||||
|
include_answer: savedProvider?.include_answer ?? preset?.include_answer ?? false,
|
||||||
|
include_raw_content: savedProvider?.include_raw_content ?? preset?.include_raw_content ?? false,
|
||||||
|
include_text: savedProvider?.include_text ?? preset?.include_text ?? false,
|
||||||
|
categories: savedProvider?.categories || preset?.categories || 'general',
|
||||||
|
engines: savedProvider?.engines || preset?.engines || [],
|
||||||
|
search_path: savedProvider?.search_path || preset?.search_path || '',
|
||||||
|
scrape_path: savedProvider?.scrape_path || preset?.scrape_path || '',
|
||||||
|
scrape_formats: savedProvider?.scrape_formats || preset?.scrape_formats || ['markdown'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setWebSearchApiKeyRevealed(false)
|
||||||
|
}
|
||||||
|
|
||||||
const setDefaultSource = (sourceId: string) => {
|
const setDefaultSource = (sourceId: string) => {
|
||||||
if (!tvSettings) return
|
if (!tvSettings) return
|
||||||
const next = { ...tvSettings, default_source_id: sourceId }
|
const next = { ...tvSettings, default_source_id: sourceId }
|
||||||
@@ -1801,7 +2029,10 @@ function Settings() {
|
|||||||
form={integrationForm}
|
form={integrationForm}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
onFinish={saveIntegrations}
|
onFinish={saveIntegrations}
|
||||||
onValuesChange={() => setAiProviderSaveFeedback(null)}
|
onValuesChange={() => {
|
||||||
|
setAiProviderSaveFeedback(null)
|
||||||
|
setWebSearchSaveFeedback(null)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Card size="small" title={<Space><ApiOutlined />LLM Provider</Space>}>
|
<Card size="small" title={<Space><ApiOutlined />LLM Provider</Space>}>
|
||||||
<Form.Item name={['ai_provider', 'provider']} label="Provider">
|
<Form.Item name={['ai_provider', 'provider']} label="Provider">
|
||||||
@@ -1932,6 +2163,119 @@ function Settings() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" title={<Space><ApiOutlined />WebSearch 证据层</Space>} style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item name={['web_search', 'enabled']} label="启用 WebSearch" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name={['web_search', 'provider']} label="WebSearch Provider">
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={webSearchPresets.map((preset) => ({
|
||||||
|
value: preset.provider,
|
||||||
|
label: preset.label,
|
||||||
|
}))}
|
||||||
|
onChange={(value) => {
|
||||||
|
applyWebSearchProviderSelection(value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
|
||||||
|
<Form.Item name={['web_search', 'base_url']} label="API Base URL">
|
||||||
|
<Input placeholder="https://api.tavily.com" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label=" ">
|
||||||
|
<Button
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
loading={testingWebSearchConnection}
|
||||||
|
onClick={() => { void testWebSearchConnection() }}
|
||||||
|
>
|
||||||
|
测试连接
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item label="WebSearch API Key">
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Space>
|
||||||
|
<Tag color={selectedWebSearchSecret?.configured ? 'green' : 'default'}>
|
||||||
|
{selectedWebSearchSecret?.configured ? '已配置' : '未配置'}
|
||||||
|
</Tag>
|
||||||
|
<Text type="secondary">保存时只会更新当前 WebSearch Provider 的 key。</Text>
|
||||||
|
</Space>
|
||||||
|
<Form.Item name={['web_search', 'api_key']} noStyle>
|
||||||
|
<Input
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="输入新的 WebSearch API key;SearXNG 可留空"
|
||||||
|
suffix={(
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={webSearchApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||||
|
onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }}
|
||||||
|
aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||||
|
<Form.Item name={['web_search', 'max_results']} label="最大结果数">
|
||||||
|
<InputNumber min={1} max={20} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'timeout_seconds']} label="超时(秒)">
|
||||||
|
<InputNumber min={3} max={120} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card size="small" type="inner" title="高级选项" style={{ marginTop: 8 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||||
|
<Form.Item name={['web_search', 'endpoint_path']} label="Endpoint Path">
|
||||||
|
<Input placeholder="/search" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'search_depth']} label="Search Depth">
|
||||||
|
<Input placeholder="basic" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'engine']} label="SerpAPI Engine">
|
||||||
|
<Input placeholder="google" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'categories']} label="SearXNG Categories">
|
||||||
|
<Input placeholder="general" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'search_path']} label="Firecrawl Search Path">
|
||||||
|
<Input placeholder="/v2/search" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'scrape_path']} label="Firecrawl Scrape Path">
|
||||||
|
<Input placeholder="/v2/scrape" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Space wrap>
|
||||||
|
<Form.Item name={['web_search', 'include_answer']} valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||||
|
<Checkbox>包含 Answer</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'include_raw_content']} valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||||
|
<Checkbox>包含 Raw Content</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['web_search', 'include_text']} valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||||
|
<Checkbox>包含 Result Text</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{webSearchSaveFeedback ? (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type={webSearchSaveFeedback.type}
|
||||||
|
message={webSearchSaveFeedback.message}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
htmlType="submit"
|
htmlType="submit"
|
||||||
@@ -2336,7 +2680,7 @@ function Settings() {
|
|||||||
className="settings-tabs"
|
className="settings-tabs"
|
||||||
activeKey={activeSettingsTab}
|
activeKey={activeSettingsTab}
|
||||||
onChange={updateSettingsTab}
|
onChange={updateSettingsTab}
|
||||||
items={tabItems}
|
items={tabItems.filter((item) => item.key !== 'ai')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2374,9 +2718,28 @@ function Settings() {
|
|||||||
) : (
|
) : (
|
||||||
<Alert showIcon type="info" message="当前显示默认教程;如果不适用,可以点击右下角“教程不好用”让 AI 重新生成。" />
|
<Alert showIcon type="info" message="当前显示默认教程;如果不适用,可以点击右下角“教程不好用”让 AI 重新生成。" />
|
||||||
)}
|
)}
|
||||||
|
{credentialGuide?.verification_status === 'unverified_no_search_evidence' ? (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="warning"
|
||||||
|
message="没有可用 WebSearch 证据,已保留默认教程。"
|
||||||
|
description={credentialGuide.verification_error || undefined}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Card loading={credentialGuideLoading} size="small" style={{ maxHeight: 520, overflow: 'auto' }}>
|
<Card loading={credentialGuideLoading} size="small" style={{ maxHeight: 520, overflow: 'auto' }}>
|
||||||
<MarkdownRenderer markdown={credentialGuide?.markdown || ''} />
|
<MarkdownRenderer markdown={credentialGuide?.markdown || ''} />
|
||||||
</Card>
|
</Card>
|
||||||
|
{credentialGuide?.sources?.length ? (
|
||||||
|
<Card size="small" title="搜索证据">
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
{credentialGuide.sources.slice(0, 5).map((source) => (
|
||||||
|
<Text key={source.url || source.title} style={{ fontSize: 12 }}>
|
||||||
|
<a href={source.url} target="_blank" rel="noreferrer">{source.title || source.url}</a>
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
{credentialGuide?.prompt ? (
|
{credentialGuide?.prompt ? (
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
生成提示词:{credentialGuide.prompt}
|
生成提示词:{credentialGuide.prompt}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "planet"
|
name = "planet"
|
||||||
version = "0.50.0"
|
version = "0.51.0"
|
||||||
description = "智能星球计划 - 态势感知系统"
|
description = "智能星球计划 - 态势感知系统"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
Reference in New Issue
Block a user