from copy import deepcopy from datetime import UTC, datetime import os from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, EmailStr, Field from dotenv import dotenv_values from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import get_current_user from app.core.time import to_iso8601_utc from app.core.config import settings as app_settings from app.core.data_sources import get_data_sources_config from app.core.datasource_defaults import DEFAULT_DATASOURCES from app.db.session import get_db from app.models.datasource import DataSource from app.models.datasource_config import DataSourceConfig from app.models.system_setting import SystemSetting from app.models.user import User from app.models.vessel import AISSourceHealth from app.schemas.ai import SituationalAnalysisRequest from app.services.barentswatch import ( BarentsWatchConfig, check_barentswatch_config, check_barentswatch_connectivity, get_barentswatch_datasource_record, resolve_barentswatch_config, ) from app.services.credential_guides import ( generate_credential_guide, get_credential_guide, reset_credential_guide, ) from app.services.datasource_connectivity import ( build_builtin_connectivity_checksum, save_connectivity_success, ) from app.services.ai_client import AIProviderClient, get_ai_provider_client from app.services.ai_tools.schemas import WebSearchConfig, WebSearchProviderConfig from app.services.ai_tools.web_search import ( WebSearchClient, WebSearchConfigurationError, WebSearchError, get_web_search_provider_preset, list_web_search_provider_presets, normalize_web_search_provider, provider_defaults as web_search_provider_defaults, ) from app.services.llm_provider_catalog import ( FALLBACK_LLM_PROVIDER_PRESETS, get_fallback_llm_provider_preset, list_fallback_llm_provider_presets, refresh_llm_provider_preset, ) from app.services.scheduler import sync_datasource_job from app.services.tv_streams import DEFAULT_TV_SETTINGS, get_tv_settings_payload, normalize_tv_settings router = APIRouter() DEFAULT_SETTINGS = { "system": { "system_name": "智能星球", "refresh_interval": 60, "auto_refresh": True, "data_retention_days": 30, "max_concurrent_tasks": 5, }, "notifications": { "email_enabled": False, "email_address": "", "critical_alerts": True, "warning_alerts": True, "daily_summary": False, }, "security": { "session_timeout": 60, "max_login_attempts": 5, "password_policy": "medium", }, "tv": DEFAULT_TV_SETTINGS, "external_integrations": { "ai_provider": { "service_url": "", "service_token": "", "default_provider": "minimax", "providers": {}, "timeout_seconds": 60, "retry_attempts": 2, }, "web_search": { "enabled": False, "default_provider": "tavily", "providers": {}, }, }, } class SystemSettingsUpdate(BaseModel): system_name: str = "智能星球" refresh_interval: int = Field(default=60, ge=10, le=3600) auto_refresh: bool = True data_retention_days: int = Field(default=30, ge=1, le=3650) max_concurrent_tasks: int = Field(default=5, ge=1, le=50) class NotificationSettingsUpdate(BaseModel): email_enabled: bool = False email_address: Optional[EmailStr] = None critical_alerts: bool = True warning_alerts: bool = True daily_summary: bool = False class SecuritySettingsUpdate(BaseModel): session_timeout: int = Field(default=60, ge=5, le=1440) max_login_attempts: int = Field(default=5, ge=1, le=20) password_policy: str = Field(default="medium") class CollectorSettingsUpdate(BaseModel): is_active: bool priority: str = Field(default="P1") frequency_minutes: int = Field(default=60, ge=1, le=10080) class TVStreamSourceUpdate(BaseModel): id: str = Field(min_length=1, max_length=100) name: str = Field(min_length=1, max_length=200) provider: str = Field(default="Unknown", max_length=100) region: str = Field(default="Global", max_length=100) language: str = Field(default="und", max_length=32) source_type: str = Field(default="iframe", pattern="^(iframe|hls|video|external|youtube)$") embed_url: str = "" stream_url: str = "" homepage_url: str = "" poster_url: str = "" youtube_video_id: str = "" youtube_channel: str = "" is_enabled: bool = True is_fallback: bool = False sort_order: int = Field(default=10, ge=0, le=9999) collector_source: Optional[str] = None notes: str = "" class TVSettingsUpdate(BaseModel): default_source_id: str = Field(default=DEFAULT_TV_SETTINGS["default_source_id"], min_length=1) auto_fallback: bool = True sources: list[TVStreamSourceUpdate] = Field(default_factory=list) class AIProviderIntegrationUpdate(BaseModel): service_url: str = "" service_token: Optional[str] = None default_provider: Optional[str] = None provider: str = Field(default="minimax", max_length=80) provider_api: str = Field(default="anthropic-messages", max_length=80) base_url: str = Field(default="", max_length=500) model: str = Field(default="", max_length=200) api_key: Optional[str] = None max_tokens: int = Field(default=1200, ge=1, le=200000) anthropic_version: str = Field(default="2023-06-01", max_length=40) timeout_seconds: int = Field(default=60, ge=5, le=600) retry_attempts: int = Field(default=2, ge=1, le=10) clear_service_token: bool = False clear_api_key: bool = False class BarentsWatchIntegrationUpdate(BaseModel): endpoint: str = "" client_id: str = "" client_secret: Optional[str] = None clear_client_secret: bool = False class WebSearchIntegrationUpdate(BaseModel): enabled: bool = False default_provider: Optional[str] = None provider: str = Field(default="tavily", max_length=80) base_url: str = Field(default="", max_length=500) api_key: Optional[str] = None max_results: int = Field(default=5, ge=1, le=20) timeout_seconds: int = Field(default=20, ge=3, le=120) endpoint_path: str = Field(default="", max_length=200) search_depth: str = Field(default="basic", max_length=40) engine: str = Field(default="google", max_length=80) include_answer: bool = False include_raw_content: bool = False include_text: bool = False categories: str = Field(default="general", max_length=120) engines: list[str] = Field(default_factory=list) search_path: str = Field(default="", max_length=200) scrape_path: str = Field(default="", max_length=200) scrape_formats: list[str] = Field(default_factory=lambda: ["markdown"]) class ExternalIntegrationsUpdate(BaseModel): ai_provider: AIProviderIntegrationUpdate barentswatch: BarentsWatchIntegrationUpdate web_search: WebSearchIntegrationUpdate | None = None def merge_with_defaults(category: str, payload: Optional[dict]) -> dict: merged = deepcopy(DEFAULT_SETTINGS[category]) if payload: merged.update(payload) return merged async def get_setting_record(db: AsyncSession, category: str) -> Optional[SystemSetting]: result = await db.execute(select(SystemSetting).where(SystemSetting.category == category)) return result.scalar_one_or_none() async def get_setting_payloads(db: AsyncSession, categories: list[str]) -> dict[str, dict]: if not categories: return {} result = await db.execute( select(SystemSetting).where(SystemSetting.category.in_(categories)) ) records_by_category = { record.category: record for record in result.scalars().all() } return { category: merge_with_defaults( category, records_by_category.get(category).payload if records_by_category.get(category) else None, ) for category in categories } async def get_setting_payload(db: AsyncSession, category: str) -> dict: record = await get_setting_record(db, category) return merge_with_defaults(category, record.payload if record else None) async def save_setting_payload(db: AsyncSession, category: str, payload: dict) -> dict: record = await get_setting_record(db, category) if record is None: record = SystemSetting(category=category, payload=payload) db.add(record) else: record.payload = payload await db.commit() await db.refresh(record) return merge_with_defaults(category, record.payload) 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: if not value: return {"configured": False, "preview": "", "source": source} text = str(value) if "-" in text: prefix = text.split("-", 1)[0] + "-" preview = prefix + ("*" * max(len(text) - len(prefix), 1)) else: preview = "*" * len(text) return {"configured": True, "preview": preview, "source": source} def _normalize_provider_id(provider: Optional[str]) -> str: return (provider or "minimax").strip().lower() or "minimax" def _get_provider_preset(provider: str) -> dict: try: return get_fallback_llm_provider_preset(provider) except ValueError: return { "provider": provider, "provider_api": "openai-completions", "base_url": "", "model": "", "models": [], "api_key_env": "", } def _read_ai_provider_env_file() -> dict[str, str]: if not AI_PROVIDER_ENV_FILE.exists(): return {} return { key: str(value) for key, value in dotenv_values(AI_PROVIDER_ENV_FILE).items() if value is not None } def _resolve_env_secret(*names: str) -> tuple[str, str]: env_file_values = _read_ai_provider_env_file() for name in names: if not name: continue value = env_file_values.get(name) if value: return value, "env_file" return "", "" def _read_web_search_env_files() -> dict[str, str]: values: dict[str, str] = {} for path in WEB_SEARCH_ENV_FILES: if not path.exists(): continue values.update({ key: str(value) for key, value in dotenv_values(path).items() if value is not None }) return values def _resolve_web_search_env_secret(*names: str) -> tuple[str, str]: env_file_values = _read_web_search_env_files() for name in names: if not name: continue value = env_file_values.get(name) if value: return value, "env_file" value = os.environ.get(name) if value: return value, "env" return "", "" def _provider_defaults(provider: str) -> dict: preset = _get_provider_preset(provider) return { "provider": provider, "provider_api": preset.get("provider_api") or "openai-completions", "base_url": preset.get("base_url") or "", "model": preset.get("model") or "", "api_key": "", "max_tokens": ( 1200 if preset.get("provider_api") == "anthropic-messages" else 4096 ), "anthropic_version": "2023-06-01", } def _normalize_ai_provider_payload(ai_payload: dict | None) -> dict: raw = dict(ai_payload or {}) default_provider = _normalize_provider_id(raw.get("default_provider") or raw.get("provider")) providers = { _normalize_provider_id(provider): dict(config or {}) for provider, config in (raw.get("providers") or {}).items() if provider } legacy_fields = { key: raw.get(key) for key in ( "provider_api", "base_url", "model", "api_key", "max_tokens", "anthropic_version", ) if raw.get(key) not in (None, "") } if legacy_fields: providers[default_provider] = { **providers.get(default_provider, {}), **legacy_fields, } normalized_providers: dict[str, dict] = {} for provider, config in providers.items(): provider_id = _normalize_provider_id(provider) normalized_providers[provider_id] = { **_provider_defaults(provider_id), **dict(config or {}), "provider": provider_id, } if default_provider not in normalized_providers: normalized_providers[default_provider] = _provider_defaults(default_provider) return { "service_url": raw.get("service_url") or "", "service_token": raw.get("service_token") or "", "default_provider": default_provider, "providers": normalized_providers, "timeout_seconds": int(raw.get("timeout_seconds") or 60), "retry_attempts": int(raw.get("retry_attempts") or 2), } def _resolve_provider_api_key(provider: str, provider_config: dict) -> tuple[str, str]: saved_key = provider_config.get("api_key") or "" if saved_key: return str(saved_key), "runtime" preset = _get_provider_preset(provider) api_key_env = preset.get("api_key_env") or "" return _resolve_env_secret(api_key_env, "AI_API_KEY") def _resolve_service_token(ai_payload: dict) -> tuple[str, str]: saved_token = ai_payload.get("service_token") or "" if saved_token: return str(saved_token), "runtime" token, source = _resolve_env_secret("AI_PROVIDER_SERVICE_TOKEN") if token: return token, source if app_settings.AI_PROVIDER_SERVICE_TOKEN: return app_settings.AI_PROVIDER_SERVICE_TOKEN, "backend_env" return "", "" def _is_secret_placeholder(value: Optional[str], current_preview: str = "") -> bool: if value in (None, ""): return True text = str(value).strip() if not text: return True return text == current_preview or text.startswith("••••") or "*" in text def _build_ai_provider_payload(current_payload: dict, update: AIProviderIntegrationUpdate) -> dict: current_ai = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {}) provider_id = _normalize_provider_id(update.default_provider or update.provider) current_providers = { provider: dict(config or {}) for provider, config in current_ai.get("providers", {}).items() } current_provider = current_providers.get(provider_id) or _provider_defaults(provider_id) current_api_key, current_api_key_source = _resolve_provider_api_key(provider_id, current_provider) current_api_key_preview = _mask_secret(current_api_key, current_api_key_source)["preview"] provider_payload = { **_provider_defaults(provider_id), **current_provider, "provider": provider_id, "provider_api": update.provider_api.strip() or current_provider.get("provider_api") or "anthropic-messages", "base_url": update.base_url.strip(), "model": update.model.strip(), "max_tokens": update.max_tokens, "anthropic_version": update.anthropic_version.strip() or "2023-06-01", } if not _is_secret_placeholder(update.api_key, current_api_key_preview): provider_payload["api_key"] = str(update.api_key).strip() elif current_provider.get("api_key"): provider_payload["api_key"] = current_provider.get("api_key") or "" else: provider_payload["api_key"] = "" current_providers[provider_id] = provider_payload current_service_token, current_service_source = _resolve_service_token(current_ai) current_service_preview = _mask_secret(current_service_token, current_service_source)["preview"] ai_payload = { "service_url": update.service_url.strip() or app_settings.AI_PROVIDER_SERVICE_URL, "service_token": current_ai.get("service_token") or "", "default_provider": provider_id, "providers": current_providers, "timeout_seconds": update.timeout_seconds, "retry_attempts": update.retry_attempts, } if not _is_secret_placeholder(update.service_token, current_service_preview): ai_payload["service_token"] = str(update.service_token).strip() return ai_payload def _runtime_config_from_ai_payload(ai_payload: dict) -> dict: normalized_ai = _normalize_ai_provider_payload(ai_payload) default_provider = normalized_ai["default_provider"] provider_config = ( normalized_ai["providers"].get(default_provider) or _provider_defaults(default_provider) ) api_key, _api_key_source = _resolve_provider_api_key(default_provider, provider_config) return { "service_url": normalized_ai.get("service_url") or app_settings.AI_PROVIDER_SERVICE_URL, "service_token": _resolve_service_token(normalized_ai)[0], "timeout_seconds": int( normalized_ai.get("timeout_seconds") or app_settings.AI_PROVIDER_TIMEOUT_SECONDS ), "retry_attempts": int( normalized_ai.get("retry_attempts") or app_settings.AI_PROVIDER_RETRY_ATTEMPTS ), "llm_config": { "provider": default_provider, "provider_api": provider_config.get("provider_api") or "anthropic-messages", "base_url": provider_config.get("base_url") or "", "model": provider_config.get("model") or "", "api_key": api_key, "max_tokens": int(provider_config.get("max_tokens") or 1200), "anthropic_version": provider_config.get("anthropic_version") or "2023-06-01", }, } def _web_search_provider_defaults(provider: str) -> dict: return web_search_provider_defaults(provider).model_dump() def _normalize_web_search_payload(web_search_payload: dict | None) -> dict: raw = dict(web_search_payload or {}) default_provider = normalize_web_search_provider( raw.get("default_provider") or raw.get("provider") ) providers = { normalize_web_search_provider(provider): dict(config or {}) for provider, config in (raw.get("providers") or {}).items() if provider } legacy_fields = { key: raw.get(key) for key in ( "base_url", "api_key", "max_results", "timeout_seconds", "endpoint_path", "search_depth", "engine", "include_answer", "include_raw_content", "include_text", "categories", "engines", "search_path", "scrape_path", "scrape_formats", ) if raw.get(key) not in (None, "") } if legacy_fields: providers[default_provider] = { **providers.get(default_provider, {}), **legacy_fields, } normalized_providers: dict[str, dict] = {} for provider, config in providers.items(): provider_id = normalize_web_search_provider(provider) normalized_providers[provider_id] = { **_web_search_provider_defaults(provider_id), **dict(config or {}), "provider": provider_id, } if default_provider not in normalized_providers: normalized_providers[default_provider] = _web_search_provider_defaults(default_provider) return { "enabled": bool(raw.get("enabled", False)), "default_provider": default_provider, "providers": normalized_providers, } def _resolve_web_search_api_key(provider: str, provider_config: dict) -> tuple[str, str]: saved_key = provider_config.get("api_key") or "" if saved_key: return str(saved_key), "runtime" preset = get_web_search_provider_preset(provider) return _resolve_web_search_env_secret(preset.get("api_key_env") or "", "WEB_SEARCH_API_KEY") def _build_web_search_payload( current_payload: dict, update: WebSearchIntegrationUpdate | None, ) -> dict: current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {}) if update is None: return current_web_search provider_id = normalize_web_search_provider(update.default_provider or update.provider) current_providers = { provider: dict(config or {}) for provider, config in current_web_search.get("providers", {}).items() } current_provider = current_providers.get(provider_id) or _web_search_provider_defaults(provider_id) current_key, current_key_source = _resolve_web_search_api_key(provider_id, current_provider) current_key_preview = _mask_secret(current_key, current_key_source)["preview"] provider_payload = { **_web_search_provider_defaults(provider_id), **current_provider, "provider": provider_id, "base_url": update.base_url.strip() or current_provider.get("base_url") or _web_search_provider_defaults(provider_id).get("base_url") or "", "max_results": update.max_results, "timeout_seconds": update.timeout_seconds, "endpoint_path": update.endpoint_path.strip() or current_provider.get("endpoint_path") or "", "search_depth": update.search_depth.strip() or "basic", "engine": update.engine.strip() or "google", "include_answer": update.include_answer, "include_raw_content": update.include_raw_content, "include_text": update.include_text, "categories": update.categories.strip() or "general", "engines": update.engines, "search_path": update.search_path.strip() or current_provider.get("search_path") or "", "scrape_path": update.scrape_path.strip() or current_provider.get("scrape_path") or "", "scrape_formats": update.scrape_formats or ["markdown"], } if not _is_secret_placeholder(update.api_key, current_key_preview): provider_payload["api_key"] = str(update.api_key).strip() elif current_provider.get("api_key"): provider_payload["api_key"] = current_provider.get("api_key") or "" else: provider_payload["api_key"] = "" current_providers[provider_id] = provider_payload return { "enabled": update.enabled, "default_provider": provider_id, "providers": current_providers, } def _runtime_config_from_web_search_payload(web_search_payload: dict) -> WebSearchConfig: normalized = _normalize_web_search_payload(web_search_payload) provider_id = normalized["default_provider"] provider_config = normalized["providers"].get(provider_id) or _web_search_provider_defaults(provider_id) api_key, _source = _resolve_web_search_api_key(provider_id, provider_config) provider_models = { provider: WebSearchProviderConfig(**{ **config, "api_key": ( api_key if provider == provider_id else _resolve_web_search_api_key(provider, config)[0] ), }) for provider, config in normalized["providers"].items() } return WebSearchConfig( enabled=normalized["enabled"], default_provider=provider_id, provider=provider_id, providers=provider_models, ) async def get_runtime_web_search_config(db: AsyncSession) -> WebSearchConfig: runtime_record = await get_setting_record(db, "external_integrations") payload = merge_with_defaults( "external_integrations", runtime_record.payload if runtime_record else None, ) return _runtime_config_from_web_search_payload(payload.get("web_search") or {}) async def get_web_search_client(db: AsyncSession) -> WebSearchClient: return WebSearchClient(await get_runtime_web_search_config(db)) async def get_runtime_ai_provider_config(db: AsyncSession) -> dict: runtime_record = await get_setting_record(db, "external_integrations") payload = merge_with_defaults( "external_integrations", runtime_record.payload if runtime_record else None, ) return _runtime_config_from_ai_payload(payload.get("ai_provider") or {}) async def get_barentswatch_config_record(db: AsyncSession) -> Optional[DataSourceConfig]: return await get_barentswatch_datasource_record(db) async def serialize_external_integrations(db: AsyncSession) -> dict: ai_config = await get_runtime_ai_provider_config(db) runtime_setting = await get_setting_record(db, "external_integrations") raw_payload = merge_with_defaults( "external_integrations", runtime_setting.payload if runtime_setting else None, ) normalized_ai = _normalize_ai_provider_payload(raw_payload.get("ai_provider") or {}) normalized_web_search = _normalize_web_search_payload(raw_payload.get("web_search") or {}) default_provider = normalized_ai["default_provider"] providers_payload: dict[str, dict] = {} for provider in sorted({ *FALLBACK_LLM_PROVIDER_PRESETS.keys(), *normalized_ai["providers"].keys(), default_provider, }): provider_id = _normalize_provider_id(provider) provider_config = normalized_ai["providers"].get(provider_id) or _provider_defaults(provider_id) api_key, api_key_source = _resolve_provider_api_key(provider_id, provider_config) providers_payload[provider_id] = { "provider": provider_id, "provider_api": provider_config.get("provider_api") or "openai-completions", "base_url": provider_config.get("base_url") or "", "model": provider_config.get("model") or "", "api_key": _mask_secret(api_key, api_key_source), "max_tokens": int(provider_config.get("max_tokens") or 1200), "anthropic_version": provider_config.get("anthropic_version") or "2023-06-01", "source": "runtime" if provider_config.get("api_key") else (api_key_source or "preset"), } display_llm_config = providers_payload.get(default_provider) or _provider_defaults(default_provider) web_search_providers_payload: dict[str, dict] = {} for provider in sorted({ *[preset["provider"] for preset in list_web_search_provider_presets()], *normalized_web_search["providers"].keys(), normalized_web_search["default_provider"], }): provider_id = normalize_web_search_provider(provider) provider_config = ( normalized_web_search["providers"].get(provider_id) or _web_search_provider_defaults(provider_id) ) api_key, api_key_source = _resolve_web_search_api_key(provider_id, provider_config) web_search_providers_payload[provider_id] = { **{ key: value for key, value in provider_config.items() if key != "api_key" }, "provider": provider_id, "api_key": _mask_secret(api_key, api_key_source), "source": "runtime" if provider_config.get("api_key") else (api_key_source or "preset"), } display_web_search_config = ( web_search_providers_payload.get(normalized_web_search["default_provider"]) or _web_search_provider_defaults(normalized_web_search["default_provider"]) ) barentswatch_record = await get_barentswatch_config_record(db) barentswatch_auth = barentswatch_record.auth_config if barentswatch_record else {} barentswatch_auth = barentswatch_auth or {} resolved_barentswatch = await resolve_barentswatch_config(db) return { "ai_provider": { "service_url": ai_config["service_url"], "service_token": _mask_secret(*_resolve_service_token(normalized_ai)), "default_provider": default_provider, "provider": default_provider, "provider_api": display_llm_config.get("provider_api") or "anthropic-messages", "base_url": display_llm_config.get("base_url") or "https://api.minimaxi.com/anthropic", "model": display_llm_config.get("model") or "MiniMax-M2.7", "api_key": display_llm_config.get("api_key") or _mask_secret(None), "providers": providers_payload, "max_tokens": int(display_llm_config.get("max_tokens") or 1200), "anthropic_version": display_llm_config.get("anthropic_version") or "2023-06-01", "timeout_seconds": ai_config["timeout_seconds"], "retry_attempts": ai_config["retry_attempts"], "source": "runtime" if runtime_setting else "env", }, "barentswatch": { "endpoint": resolved_barentswatch.endpoint, "client_id": barentswatch_auth.get("client_id") or resolved_barentswatch.client_id, "client_secret": _mask_secret( barentswatch_auth.get("client_secret") or resolved_barentswatch.client_secret ), "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", }, } async def save_external_integrations_payload( db: AsyncSession, update: ExternalIntegrationsUpdate, ) -> dict: current_payload = await get_setting_payload(db, "external_integrations") ai_payload = _build_ai_provider_payload(current_payload, update.ai_provider) web_search_payload = _build_web_search_payload(current_payload, update.web_search) await save_setting_payload( db, "external_integrations", {"ai_provider": ai_payload, "web_search": web_search_payload}, ) default_endpoint = get_data_sources_config().get_yaml_url("barentswatch_vessels") barentswatch_record = await get_barentswatch_config_record(db) if barentswatch_record is None: barentswatch_record = DataSourceConfig( name="barentswatch_vessels", description="BarentsWatch Live AIS credentials", source_type="api", endpoint=update.barentswatch.endpoint.strip() or default_endpoint, auth_type="oauth_client", auth_config={}, headers={}, config={}, is_active=True, ) db.add(barentswatch_record) current_auth = dict(barentswatch_record.auth_config or {}) if update.barentswatch.clear_client_secret: current_auth.pop("client_secret", None) elif update.barentswatch.client_secret not in (None, ""): current_auth["client_secret"] = update.barentswatch.client_secret current_auth["client_id"] = update.barentswatch.client_id.strip() barentswatch_record.endpoint = update.barentswatch.endpoint.strip() or default_endpoint barentswatch_record.auth_type = "oauth_client" barentswatch_record.auth_config = current_auth await db.commit() return await serialize_external_integrations(db) def format_frequency_label(minutes: int) -> str: if minutes % 1440 == 0: return f"{minutes // 1440}d" if minutes % 60 == 0: return f"{minutes // 60}h" return f"{minutes}m" async def get_ais_source_health_by_source(db: AsyncSession) -> dict[str, dict]: result = await db.execute(select(AISSourceHealth)) return {item.source: item.to_dict() for item in result.scalars().all()} def serialize_collector(datasource: DataSource, ais_health_by_source: dict[str, dict] | None = None) -> dict: defaults = DEFAULT_DATASOURCES.get(datasource.source, {}) return { "id": datasource.id, "name": datasource.name, "display_name": defaults.get("display_name") or datasource.name, "source": datasource.source, "module": datasource.module, "priority": datasource.priority, "frequency_minutes": datasource.frequency_minutes, "frequency": format_frequency_label(datasource.frequency_minutes), "is_active": datasource.is_active, "last_run_at": to_iso8601_utc(datasource.last_run_at), "last_status": datasource.last_status, "next_run_at": to_iso8601_utc(datasource.next_run_at), "is_free": bool(defaults.get("is_free", True)), "requires_credentials": bool(defaults.get("requires_credentials", False)), "credential_provider": defaults.get("credential_provider"), "credential_status": defaults.get("credential_status", "none"), "ais_health": (ais_health_by_source or {}).get(datasource.source), } @router.get("/system") async def get_system_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return {"system": await get_setting_payload(db, "system")} @router.put("/system") async def update_system_settings( settings: SystemSettingsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): payload = await save_setting_payload(db, "system", settings.model_dump()) return {"status": "updated", "system": payload} @router.get("/notifications") async def get_notification_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return {"notifications": await get_setting_payload(db, "notifications")} @router.put("/notifications") async def update_notification_settings( settings: NotificationSettingsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): payload = await save_setting_payload(db, "notifications", settings.model_dump()) return {"status": "updated", "notifications": payload} @router.get("/security") async def get_security_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return {"security": await get_setting_payload(db, "security")} @router.put("/security") async def update_security_settings( settings: SecuritySettingsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): payload = await save_setting_payload(db, "security", settings.model_dump()) return {"status": "updated", "security": payload} @router.get("/tv") async def get_tv_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return {"tv": await get_tv_settings_payload(db)} @router.put("/tv") async def update_tv_settings( settings: TVSettingsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): payload = normalize_tv_settings(settings.model_dump()) saved = await save_setting_payload(db, "tv", payload) return {"status": "updated", "tv": normalize_tv_settings(saved)} @router.get("/integrations") async def get_external_integrations( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return {"integrations": await serialize_external_integrations(db)} @router.get("/integrations/barentswatch/connectivity") async def get_barentswatch_connectivity( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return await check_barentswatch_connectivity(db) @router.post("/integrations/barentswatch/connect") async def connect_barentswatch_integration( payload: BarentsWatchIntegrationUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): current = await resolve_barentswatch_config(db) config = BarentsWatchConfig( endpoint=payload.endpoint.strip() or current.endpoint, client_id=payload.client_id.strip() or current.client_id, client_secret=( "" if payload.clear_client_secret else payload.client_secret or current.client_secret ), credential_source="draft", endpoint_source="draft", ) result = await check_barentswatch_config(config) if result.get("success"): checksum, _context = await build_builtin_connectivity_checksum( "barentswatch_vessels", config.endpoint, "none", {}, {}, db, credential_override={ "client_id": config.client_id, "client_secret": config.client_secret, }, ) validation = await save_connectivity_success( db, "barentswatch_vessels", checksum, result, connected_by="connection_button", ) await db.commit() return {**result, "connected": True, "validation": validation} return {**result, "connected": False} @router.post("/integrations/ai-provider/connect") async def connect_ai_provider_integration( payload: AIProviderIntegrationUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): current_payload = await get_setting_payload(db, "external_integrations") draft_ai_payload = _build_ai_provider_payload(current_payload, payload) runtime_config = _runtime_config_from_ai_payload(draft_ai_payload) client = AIProviderClient( service_url=runtime_config["service_url"], service_token=runtime_config["service_token"], timeout=runtime_config["timeout_seconds"], retry_attempts=runtime_config["retry_attempts"], llm_config=runtime_config.get("llm_config") or {}, ) try: status_result = await client.get_status() if not status_result.configured: return { "success": False, "connected": False, "message": "AI Provider 可访问,但当前 provider/model/key 未完整配置。", "status": status_result.model_dump(), } analysis_result = await client.analyze( SituationalAnalysisRequest( title="连接测试", objective="请用一句话回复连接可用。", observations=["这是配置中心发起的 LLM 连接测试。"], constraints=["回复尽量简短。"], ) ) current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {}) await save_setting_payload( db, "external_integrations", {"ai_provider": draft_ai_payload, "web_search": current_web_search}, ) return { "success": True, "connected": True, "message": "AI Provider 连接成功,已保存为全局默认配置。", "status": status_result.model_dump(), "provider": analysis_result.provider, "model": analysis_result.model, "integrations": await serialize_external_integrations(db), } except HTTPException as exc: return { "success": False, "connected": False, "message": str(exc.detail), } except Exception as exc: return { "success": False, "connected": False, "message": f"AI Provider 连接测试失败: {exc}", } @router.get("/integrations/ai-provider/secrets") async def reveal_ai_provider_secrets( provider: str = Query(default=""), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): current_payload = await get_setting_payload(db, "external_integrations") ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {}) provider_id = _normalize_provider_id(provider or ai_payload["default_provider"]) provider_config = ai_payload["providers"].get(provider_id) or _provider_defaults(provider_id) api_key, api_key_source = _resolve_provider_api_key(provider_id, provider_config) service_token, service_token_source = _resolve_service_token(ai_payload) return { "provider": provider_id, "api_key": api_key, "api_key_source": api_key_source, "service_token": service_token, "service_token_source": service_token_source, } @router.get("/integrations/web-search/presets") async def get_web_search_presets( current_user: User = Depends(get_current_user), ): return {"data": list_web_search_provider_presets()} @router.get("/integrations/web-search/secrets") async def reveal_web_search_secrets( provider: str = Query(default=""), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): current_payload = await get_setting_payload(db, "external_integrations") web_search_payload = _normalize_web_search_payload(current_payload.get("web_search") or {}) provider_id = normalize_web_search_provider(provider or web_search_payload["default_provider"]) provider_config = ( web_search_payload["providers"].get(provider_id) or _web_search_provider_defaults(provider_id) ) api_key, api_key_source = _resolve_web_search_api_key(provider_id, provider_config) return { "provider": provider_id, "api_key": api_key, "api_key_source": api_key_source, } @router.post("/integrations/web-search/connect") async def connect_web_search_integration( payload: WebSearchIntegrationUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): current_payload = await get_setting_payload(db, "external_integrations") draft_web_search_payload = _build_web_search_payload(current_payload, payload) runtime_config = _runtime_config_from_web_search_payload(draft_web_search_payload) client = WebSearchClient(runtime_config) try: results = await client.test_connection() return { "success": True, "connected": True, "message": "WebSearch 连接成功。", "provider": runtime_config.default_provider, "results": [item.model_dump(mode="json") for item in results[:3]], } except WebSearchConfigurationError as exc: return { "success": False, "connected": False, "message": str(exc), } except WebSearchError as exc: return { "success": False, "connected": False, "message": str(exc), } except Exception as exc: return { "success": False, "connected": False, "message": f"WebSearch 连接测试失败: {exc}", } @router.get("/credential-guides/{provider}") async def read_credential_guide( provider: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): try: return {"guide": await get_credential_guide(db, provider)} except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @router.post("/credential-guides/{provider}/generate") async def generate_provider_credential_guide( provider: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ai_client: AIProviderClient = Depends(get_ai_provider_client), ): try: web_search_client = await get_web_search_client(db) return { "guide": await generate_credential_guide( db, provider, ai_client, web_search_client, ) } except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @router.post("/credential-guides/{provider}/reset") async def reset_provider_credential_guide( provider: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): try: return {"guide": await reset_credential_guide(db, provider)} except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @router.get("/integrations/ai-provider/presets") async def get_ai_provider_presets( current_user: User = Depends(get_current_user), ): return {"data": list_fallback_llm_provider_presets()} @router.post("/integrations/ai-provider/presets/{provider}/refresh") async def refresh_ai_provider_preset( provider: str, current_user: User = Depends(get_current_user), ): try: return {"data": await refresh_llm_provider_preset(provider)} except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except Exception as exc: fallback = get_fallback_llm_provider_preset(provider) fallback["refresh_error"] = str(exc) return {"data": fallback} @router.put("/integrations") async def update_external_integrations( payload: ExternalIntegrationsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): saved = await save_external_integrations_payload(db, payload) return {"status": "updated", "integrations": saved} @router.get("/collectors") async def get_collector_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id)) datasources = result.scalars().all() ais_health_by_source = await get_ais_source_health_by_source(db) return {"collectors": [serialize_collector(datasource, ais_health_by_source) for datasource in datasources]} @router.put("/collectors/{datasource_id}") async def update_collector_settings( datasource_id: int, settings: CollectorSettingsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): datasource = await db.get(DataSource, datasource_id) if not datasource: raise HTTPException(status_code=404, detail="Data source not found") datasource.is_active = settings.is_active datasource.priority = settings.priority datasource.frequency_minutes = settings.frequency_minutes await db.commit() await db.refresh(datasource) await sync_datasource_job(datasource.id) ais_health_by_source = await get_ais_source_health_by_source(db) return {"status": "updated", "collector": serialize_collector(datasource, ais_health_by_source)} @router.get("") async def get_all_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id)) datasources = result.scalars().all() setting_payloads = await get_setting_payloads( db, ["system", "notifications", "security"], ) ais_health_by_source = await get_ais_source_health_by_source(db) return { "system": setting_payloads["system"], "notifications": setting_payloads["notifications"], "security": setting_payloads["security"], "tv": await get_tv_settings_payload(db), "integrations": await serialize_external_integrations(db), "collectors": [serialize_collector(datasource, ais_health_by_source) for datasource in datasources], "generated_at": to_iso8601_utc(datetime.now(UTC)), }