Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81970a1d05 | ||
|
|
9b913a3b83 | ||
|
|
93eb41a9f7 | ||
|
|
dd176a6ae6 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -8,6 +8,7 @@
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
config/earth-boundary-sources.local.json
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
@@ -150,3 +151,9 @@ temp/
|
||||
# Runtime Data
|
||||
# ----------------------
|
||||
data/ai/bgp-briefs/
|
||||
data/earth-boundary-sources/
|
||||
|
||||
# Generated Earth boundary tile artifacts. Keep source configs and builders in
|
||||
# Git; publish PMTiles/MVT artifacts through release/deploy storage instead of
|
||||
# committing thousands of generated tile files.
|
||||
frontend/public/earth/data/boundaries/
|
||||
|
||||
250
README.md
250
README.md
@@ -8,68 +8,54 @@
|
||||
|
||||
## 系统架构
|
||||
|
||||
当前仓库的核心形态是“Web Earth 可视化 + React 运维台 + FastAPI 数据与 AI 编排后端 + 独立模型适配层”。物理大屏与 UE 客户端仍是长期方向,但不再作为本地开发和当前发布的必需运行单元。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 物理大屏展示层 │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 偏振片3D大屏 (2m×3m, 4K, 120Hz, 眼镜式) │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ 虚幻引擎 UE5 客户端 │ │ │
|
||||
│ │ │ ├── 3D地球渲染 (Cesium for UE) │ │ │
|
||||
│ │ │ ├── 算力点可视化 (GPU集群、智算中心) │ │ │
|
||||
│ │ │ ├── 连接弧线 (光缆、路由、数据流向) │ │ │
|
||||
│ │ │ ├── 粒子效果 (数据流动、告警提示) │ │ │
|
||||
│ │ │ └── 自动巡航相机 + 交互控制 │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ WebSocket (实时推送)
|
||||
│ 120Hz 心跳 / 数据帧同步
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 数据中台服务层 (FastAPI) │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ API Gateway (Redis 限流) │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────┬──────────────────────────┬──────────────────┐ │
|
||||
│ │ 数据采集服务 │ 核心业务服务 │ 运维管理服务 │ │
|
||||
│ │ ┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐ │ │
|
||||
│ │ │ 调度中心 │ │ │ WebSocket 服务 │ │ │ 用户管理 │ │ │
|
||||
│ │ │ (Celery) │ │ │ (FastAPI) │ │ │ (JWT Auth) │ │ │
|
||||
│ │ └─────────────┘ │ └─────────────────┘ │ └─────────────┘ │ │
|
||||
│ │ ┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐ │ │
|
||||
│ │ │ 采集器池 │ │ │ 数据查询 API │ │ │ 数据源配置 │ │ │
|
||||
│ │ │ (10+源) │ │ │ (REST) │ │ │ 监控告警 │ │ │
|
||||
│ │ └─────────────┘ │ └─────────────────┘ │ └─────────────┘ │ │
|
||||
│ │ ┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐ │ │
|
||||
│ │ │ 消息队列 │ │ │ 态势分析引擎 │ │ │ 系统配置 │ │ │
|
||||
│ │ │ (Kafka) │ │ │ (计算/聚合) │ │ │ 日志审计 │ │ │
|
||||
│ │ └─────────────┘ │ └─────────────────┘ │ └─────────────┘ │ │
|
||||
│ └───────────────────┴──────────────────────────┴──────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ 内部 API 调用
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Web管理端 (React Admin) │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 登录页 │ 仪表盘 │ 用户管理 │ 数据源配置 │ 任务监控 │ 系统配置 │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ PostgreSQL / Redis
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 数据存储层 │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ PostgreSQL │ │ TimescaleDB │ │ Redis │ │ MinIO │ │
|
||||
│ │ (用户/配置) │ │ (时序数据) │ │ (缓存/会话) │ │ (文件存储) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 浏览器展示与运维层 │
|
||||
│ ┌──────────────────────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ Web Earth │ │ React 运维台 │ │
|
||||
│ │ frontend/public/earth │ │ frontend/src │ │
|
||||
│ │ Three.js 地球 / HUD / 新闻 │ │ 数据源 / 告警 / AI 设置 │ │
|
||||
│ │ 国界精度 / 品牌内容配置 │ │ 提示词配置 / 用户与系统配置 │ │
|
||||
│ └──────────────────────────────┘ └──────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│ REST / WebSocket
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ FastAPI 业务与编排后端 │
|
||||
│ ┌────────────────────┐ ┌────────────────────┐ ┌─────────────────┐ │
|
||||
│ │ 数据 API 与认证 │ │ Earth 新闻增强 │ │ 告警与态势简报 │ │
|
||||
│ │ JWT / 权限 / 审计 │ │ 位置推断 / 本地化 │ │ BGP / 告警研判 │ │
|
||||
│ └────────────────────┘ └────────────────────┘ └─────────────────┘ │
|
||||
│ ┌────────────────────┐ ┌────────────────────┐ ┌─────────────────┐ │
|
||||
│ │ 系统运行配置 │ │ 默认提示词注册表 │ │ 未来 Agent Runtime│ │
|
||||
│ │ system_settings │ │ 代码发布 + DB 覆盖 │ │ 工具/证据/工作流 │ │
|
||||
│ └────────────────────┘ └────────────────────┘ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│ SQLAlchemy / Redis Stream │ 纯净 LLM 调用
|
||||
▼ ▼
|
||||
┌──────────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ PostgreSQL / Redis │ │ aiprovider │
|
||||
│ 用户、配置、采集结果、新闻 │ │ provider + protocol adapter │
|
||||
│ Stream、缓存、运行状态 │ │ OpenAI / MiniMax / Ollama 等 │
|
||||
└──────────────────────────────┘ └──────────────────────────────┘
|
||||
▲
|
||||
│ 采集器 / 外部数据源
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ RSS 新闻、BGP 观测、公开数据源、后续 WebSearch/OCR/语音识别等工具 │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
架构边界:
|
||||
|
||||
- `backend` 负责业务语义、证据收集、提示词选择、AI 任务编排、权限和数据落库。
|
||||
- `aiprovider` 只负责把纯净模型请求适配到不同供应商或协议,不内置具体业务提示词。
|
||||
- 默认提示词随代码发布并保存在 `backend/app/ai_tasks/default_prompts.json`,运维台可在数据库中保存覆盖值,重置时回到当前代码版本的默认提示词。
|
||||
- Earth 新闻保留英文原文,中文展示结果存入 `localizations`,前端默认展示 `zh-CN` 的 `display_title`、`display_summary` 和中文地域/状态文案。
|
||||
- Earth LLM 指令、语音识别、多角色态势研判属于后续 Agent Runtime 方向,计划见 [docs/plans/agents-earth-command-runtime-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md)。
|
||||
|
||||
## 四大核心要素
|
||||
|
||||
| 层级 | 要素 | 描述 |
|
||||
@@ -87,11 +73,10 @@
|
||||
|------|------|------|
|
||||
| FastAPI | 0.109+ | Web 框架 |
|
||||
| SQLAlchemy | 2.0+ | ORM |
|
||||
| Alembic | - | 数据库迁移 |
|
||||
| Celery | 5.3+ | 任务队列 |
|
||||
| Redis | 7.0+ | 缓存/消息 |
|
||||
| Kafka | 3.0+ | 事件流 |
|
||||
| uv | - | Python 依赖与命令运行 |
|
||||
| Redis | 7.0+ | 缓存、Stream 与运行协调 |
|
||||
| PyJWT | - | 认证 |
|
||||
| APScheduler / 后台任务 | - | 采集、增强与运行时任务 |
|
||||
|
||||
### 前端 (React Admin)
|
||||
|
||||
@@ -102,6 +87,7 @@
|
||||
| Axios | HTTP 客户端 |
|
||||
| Socket.io-client | WebSocket 客户端 |
|
||||
| ECharts | 统计图表 |
|
||||
| Three.js | Earth 3D 地球渲染 |
|
||||
| Bun | 前端包管理与脚本运行 |
|
||||
|
||||
前端工程统一使用 Bun:
|
||||
@@ -110,22 +96,16 @@
|
||||
- 运行脚本使用 `bun run <script>`
|
||||
- 不使用 `npm`、`pnpm`、`yarn`
|
||||
|
||||
### 虚幻引擎客户端
|
||||
### 大屏与 3D 展示方向
|
||||
|
||||
| 组件 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| Unreal Engine 5 | 5.3+ | 3D 渲染引擎 |
|
||||
| Cesium for Unreal | 1.5+ | 地理可视化 |
|
||||
| Niagara | - | 粒子系统 |
|
||||
当前发布优先使用浏览器 Web Earth。UE5 / Cesium for Unreal / Niagara 可作为后续物理大屏方向接入,但不是本地开发闭环的必需组件。
|
||||
|
||||
### 数据库
|
||||
|
||||
| 组件 | 用途 |
|
||||
|------|------|
|
||||
| PostgreSQL 15+ | 关系数据 |
|
||||
| TimescaleDB | 时序数据扩展 |
|
||||
| Redis 7+ | 缓存/会话 |
|
||||
| MinIO | S3 兼容存储 |
|
||||
| Redis 7+ | 缓存、Stream、运行状态 |
|
||||
|
||||
### 部署
|
||||
|
||||
@@ -152,9 +132,9 @@
|
||||
| P0 | Epoch AI | 每小时 |
|
||||
| P0 | Hugging Face | 每 2 小时 |
|
||||
| P0 | GitHub | 每 4 小时 |
|
||||
| P0 每日 |
|
||||
| P0 | 海底光缆 / IXP / 卫星等基础设施数据 | 每日或按源刷新 |
|
||||
| P0 | PeeringDB | 每 2 小时 |
|
||||
| P1 | Cloudflare Radar | | TeleGeography | 每小时 |
|
||||
| P1 | Cloudflare Radar / TeleGeography | 每小时 |
|
||||
| P1 | CAIDA BGPStream | 每 15 分钟 |
|
||||
|
||||
## 项目结构
|
||||
@@ -166,20 +146,18 @@
|
||||
│ │ ├── core/ # 核心配置
|
||||
│ │ ├── models/ # 数据模型
|
||||
│ │ ├── schemas/ # Pydantic 模型
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ └── tasks/ # Celery 任务
|
||||
│ │ ├── services/ # 业务逻辑与 AI 任务编排
|
||||
│ │ └── ai_tasks/ # 默认提示词与 AI 任务定义
|
||||
│ └── tests/
|
||||
├── aiprovider/ # 独立模型供应商适配层
|
||||
├── frontend/ # React 管理后台
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # 组件
|
||||
│ │ ├── pages/ # 页面
|
||||
│ │ ├── services/ # API 服务
|
||||
│ │ └── store/ # 状态管理
|
||||
│ └── tests/
|
||||
├── unreal/ # UE5 大屏客户端
|
||||
│ ├── Content/
|
||||
│ ├── Source/
|
||||
│ └── Plugins/
|
||||
│ ├── public/earth/ # Web Earth 静态应用
|
||||
│ └── tests/ # 前端测试
|
||||
├── data/ # 数据文件
|
||||
├── docs/ # 文档
|
||||
├── scripts/ # 脚本
|
||||
@@ -244,7 +222,7 @@ bun run build
|
||||
./planet.sh start --allow-lan
|
||||
```
|
||||
|
||||
这会让前端监听 `0.0.0.0:3000`,后端监听 `0.0.0.0:8000`。
|
||||
这会让前端监听 `0.0.0.0:3000`,后端监听 `0.0.0.0:8000`,AI Provider 通过 Docker 发布到 `0.0.0.0:8010`。启动前脚本会检查这三个端口;如果 WSL/Linux 侧无法释放端口,并检测到 Windows 侧 listener 或旧 `portproxy`,会请求管理员 PowerShell 清理。
|
||||
|
||||
### 2. 先确认 WSL 内部服务正常
|
||||
|
||||
@@ -253,14 +231,16 @@ bun run build
|
||||
```bash
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
ss -ltnp | grep -E ':3000|:8000'
|
||||
curl http://localhost:8010/health
|
||||
ss -ltnp | grep -E ':3000|:8000|:8010'
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- `3000` 返回前端 HTML
|
||||
- `8000/health` 返回健康检查 JSON
|
||||
- `ss` 中能看到 `0.0.0.0:3000` 和 `0.0.0.0:8000`
|
||||
- `8010/health` 返回 AI Provider 健康检查 JSON
|
||||
- `ss` 中能看到 `0.0.0.0:3000`、`0.0.0.0:8000` 和 `0.0.0.0:8010`,或 Docker 已发布 `8010`
|
||||
|
||||
如果这一步不通,先不要继续做 Windows 转发。
|
||||
|
||||
@@ -271,42 +251,31 @@ ss -ltnp | grep -E ':3000|:8000'
|
||||
```powershell
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
curl http://localhost:8010/health
|
||||
```
|
||||
|
||||
在常见的 WSL2 开发环境下,Windows 通常可以直接通过 `localhost` 访问 WSL 中的服务。
|
||||
|
||||
### 4. 如果需要让局域网设备访问,再做 Windows 端口转发
|
||||
### 4. 如果需要让局域网设备访问,清理端口和防火墙
|
||||
|
||||
注意:下面的命令必须在“以管理员身份运行”的 PowerShell 中执行。
|
||||
`./planet.sh start --allow-lan` 不再启动额外的 Windows 端口转发进程。它直接让开发服务对 `3000` / `8000` / `8010` 开放,并在启动前尝试释放这些端口。端口被 Windows 侧 listener 或旧 `portproxy` 占用时,脚本会请求一次管理员 PowerShell 清理。
|
||||
|
||||
先把 Windows 对外网卡上的 `3000` / `8000` 转发到 Windows 本机 `127.0.0.1`:
|
||||
如果以前手动配置过持久 `portproxy`,若自动请求被取消,可以手动清理,避免 `iphlpsvc` 继续占用端口:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000
|
||||
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8010
|
||||
```
|
||||
|
||||
再放行 Windows 防火墙:
|
||||
脚本会检测 Windows 防火墙是否已放行 `3000` / `8000` / `8010`。如果缺少规则,会触发一次 Windows UAC 管理员 PowerShell 请求来自动创建。若自动请求被取消,也可以手动执行:
|
||||
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010
|
||||
```
|
||||
|
||||
检查转发规则是否生效:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy show all
|
||||
```
|
||||
|
||||
预期能看到:
|
||||
|
||||
- `0.0.0.0:3000 -> 127.0.0.1:3000`
|
||||
- `0.0.0.0:8000 -> 127.0.0.1:8000`
|
||||
|
||||
### 5. 查 Windows 局域网 IP,并让其他设备访问
|
||||
|
||||
在 Windows PowerShell 中执行:
|
||||
@@ -321,6 +290,8 @@ ipconfig
|
||||
|
||||
- `http://<Windows局域网IP>:3000/earth`
|
||||
- `http://<Windows局域网IP>:3000/admin`
|
||||
- `http://<Windows局域网IP>:8000/health`
|
||||
- `http://<Windows局域网IP>:8010/health`
|
||||
|
||||
例如:
|
||||
|
||||
@@ -329,7 +300,7 @@ ipconfig
|
||||
### 6. 常见现象与判断
|
||||
|
||||
- WSL 中 `curl localhost:3000` 能通,但 Windows 访问 `WSL 的局域网 IP:3000` 不通:这是正常现象之一,优先验证 Windows 的 `localhost:3000`
|
||||
- Windows `localhost:3000` 能通,但局域网设备访问 `Windows 局域网 IP:3000` 不通:通常缺少 `portproxy` 或防火墙放行
|
||||
- Windows `localhost:3000` 能通,但局域网设备访问 `Windows 局域网 IP:3000` 不通:通常是 Windows 防火墙、网络配置或旧 `portproxy` 残留
|
||||
- `whoami /groups` 中 `S-1-5-32-544` 显示 `deny only`:说明当前 PowerShell 不是提权管理员窗口
|
||||
|
||||
### 7. 本项目一次性验证顺序
|
||||
@@ -338,10 +309,12 @@ ipconfig
|
||||
|
||||
1. WSL 中执行 `curl http://localhost:3000`
|
||||
2. WSL 中执行 `curl http://localhost:8000/health`
|
||||
3. Windows 中执行 `curl http://localhost:3000`
|
||||
4. Windows 中执行 `curl http://localhost:8000/health`
|
||||
5. 管理员 PowerShell 配置 `portproxy` 和防火墙
|
||||
6. 用手机或其他电脑访问 `http://<Windows局域网IP>:3000/earth`
|
||||
3. WSL 中执行 `curl http://localhost:8010/health`
|
||||
4. Windows 中执行 `curl http://localhost:3000`
|
||||
5. Windows 中执行 `curl http://localhost:8000/health`
|
||||
6. Windows 中执行 `curl http://localhost:8010/health`
|
||||
7. 按脚本提示完成 Windows 防火墙或端口清理 UAC 请求
|
||||
8. 用手机或其他电脑访问 Windows 对外端口,例如 `http://<Windows局域网IP>:3000/earth`
|
||||
|
||||
## 启动容错参数
|
||||
|
||||
@@ -369,14 +342,23 @@ DATABASE_RETRY_INTERVAL=10 \
|
||||
- `FRONTEND_HEALTH_CHECK_ATTEMPTS` / `FRONTEND_HEALTH_CHECK_INTERVAL`: 控制前端 HTTP 可访问检查等待次数与间隔,默认 `10` 次、`2` 秒
|
||||
- `AI_PROVIDER_HEALTH_CHECK_ATTEMPTS` / `AI_PROVIDER_HEALTH_CHECK_INTERVAL`: 控制 `aiprovider` HTTP 健康检查等待次数与间隔,默认 `10` 次、`2` 秒
|
||||
|
||||
## AI 接口预留
|
||||
## AI 与智能体接口
|
||||
|
||||
项目现在采用“两层”设计:
|
||||
项目现在采用“三段式”边界:
|
||||
|
||||
- 主后端暴露稳定业务接口: `GET /api/v1/ai/provider/status`、`POST /api/v1/ai/situational-awareness/analyze`
|
||||
- 独立 `aiprovider` 服务负责适配具体模型供应商
|
||||
- `backend`: 暴露业务接口,负责选择任务提示词、组织证据、调用工具、保存 AI 设置和结果。
|
||||
- `aiprovider`: 暴露模型网关接口,只负责 provider / protocol 适配,不写入 BGP、新闻、告警等业务提示词。
|
||||
- 模型供应商: OpenAI 兼容、MiniMax、Anthropic、Ollama 或其他兼容网关。
|
||||
|
||||
这样前端和业务代码不直接依赖 OpenAI、本地模型网关或其他订阅服务,后续切换部署方式只需要调整环境变量。
|
||||
这样前端和业务代码不直接依赖某个模型供应商,后续增加 Agent Runtime、Earth 一键 LLM 指令、语音识别或多角色态势研判时,也可以把业务工作流放在后端,而不是污染模型适配层。
|
||||
|
||||
当前已落地的 AI 配置能力:
|
||||
|
||||
- 运维台 AI 设置可维护 provider、模型、协议、超时、token 等运行配置。
|
||||
- 运维台 AI 设置中的“提示词”页可选择不同功能入口,手动覆盖提示词,并一键重置到默认值。
|
||||
- 默认提示词随代码发布,位于 [backend/app/ai_tasks/default_prompts.json](/home/ray/dev/linkong/planet/backend/app/ai_tasks/default_prompts.json)。
|
||||
- 覆盖值保存在数据库运行配置中,升级代码后可继续保留现场配置,也可重置到新版本默认提示词。
|
||||
- 态势摘要、告警研判、新闻本地化等入口应使用各自任务提示词;调用 `aiprovider` 时只传递当前任务所需的 `prompt` / `system_prompt`。
|
||||
|
||||
主后端建议配置:
|
||||
|
||||
@@ -389,39 +371,33 @@ AI_PROVIDER_TIMEOUT_SECONDS=60
|
||||
`aiprovider` 服务建议配置:
|
||||
|
||||
```env
|
||||
AI_PROVIDER=openai_compatible
|
||||
AI_BASE_URL=https://api.openai.com/v1
|
||||
AI_PROVIDER=minimax
|
||||
AI_PROVIDER_API=anthropic-messages
|
||||
AI_BASE_URL=https://api.minimaxi.com/anthropic
|
||||
AI_API_KEY=your_api_key
|
||||
AI_MODEL=gpt-4o-mini
|
||||
AI_MODEL=MiniMax-M2.7
|
||||
AI_TIMEOUT_SECONDS=60
|
||||
AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
```
|
||||
|
||||
OpenAI 兼容场景推荐使用:
|
||||
推荐映射关系:
|
||||
|
||||
- `AI_PROVIDER=openai_compatible`
|
||||
- `vLLM` / `LM Studio` / `One API`: `AI_PROVIDER=openai` + `AI_PROVIDER_API=openai-completions`
|
||||
- `MiniMax`: `AI_PROVIDER=minimax` + `AI_PROVIDER_API=anthropic-messages`
|
||||
- Claude 兼容网关: `AI_PROVIDER=anthropic` + `AI_PROVIDER_API=anthropic-messages`
|
||||
- `Ollama`: `AI_PROVIDER=ollama` + `AI_PROVIDER_API=ollama-generate`
|
||||
|
||||
Claude 兼容场景推荐使用:
|
||||
|
||||
- `AI_PROVIDER=anthropic`
|
||||
- `AI_PROVIDER=anthropic_compatible`
|
||||
- `AI_PROVIDER=claude_compatible`
|
||||
|
||||
Ollama 原生场景推荐使用:
|
||||
|
||||
- `AI_PROVIDER=ollama`
|
||||
|
||||
比如 MiniMax 或其他 Claude 兼容网关,可以这样配置:
|
||||
比如 MiniMax 可以这样配置:
|
||||
|
||||
```env
|
||||
AI_PROVIDER=claude_compatible
|
||||
AI_BASE_URL=https://your-claude-compatible-endpoint.example.com
|
||||
AI_PROVIDER=minimax
|
||||
AI_PROVIDER_API=anthropic-messages
|
||||
AI_BASE_URL=https://api.minimaxi.com/anthropic
|
||||
AI_API_KEY=your_api_key
|
||||
AI_MODEL=your-claude-compatible-model
|
||||
AI_MODEL=MiniMax-M2.7
|
||||
AI_TIMEOUT_SECONDS=60
|
||||
AI_MAX_TOKENS=1200
|
||||
AI_ANTHROPIC_VERSION=2023-06-01
|
||||
AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
```
|
||||
|
||||
如果你要本地直接起模型适配层,项目里已经补了模板:
|
||||
@@ -429,12 +405,6 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
- [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example)
|
||||
- [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml)
|
||||
|
||||
推荐映射关系:
|
||||
|
||||
- `vLLM` / `LM Studio` / `One API`: `AI_PROVIDER=openai_compatible`
|
||||
- `MiniMax` / Claude 兼容网关: `AI_PROVIDER=claude_compatible`
|
||||
- `Ollama`: `AI_PROVIDER=ollama`
|
||||
|
||||
运行与调用补充:
|
||||
|
||||
- `./planet.sh start` 默认会启动 `aiprovider`
|
||||
@@ -444,11 +414,13 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
|
||||
详细文档:
|
||||
|
||||
- [docs/technical/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md)
|
||||
- [docs/technical/zh/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/zh/agents-aiprovider.md)
|
||||
- [docs/technical/en/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/en/agents-aiprovider.md)
|
||||
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
|
||||
- [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
|
||||
- [docs/plans/frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
|
||||
- [docs/plans/agents-situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md)
|
||||
- [docs/plans/agents-earth-command-runtime-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md)
|
||||
|
||||
## 前端页面布局规范
|
||||
|
||||
|
||||
16
TODO.md
16
TODO.md
@@ -4,8 +4,16 @@ This file is the active backlog only. Completed history belongs in `docs/CHANGEL
|
||||
|
||||
## Earth
|
||||
|
||||
- [ ] High-precision country boundaries: implement the source-faithful static vector tile pipeline described in [Earth High Precision Boundary Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-precision-boundary-tiles-plan.md). Highest zoom must preserve trusted source geometry instead of smoothing or hand-drawing borders.
|
||||
- [ ] Earth boundary data: acquire or generate auditable China POV geometry for Zangnan, Aksai Chin, Taiwan/Penghu, Diaoyu Dao and affiliated islands, Chiwei Yu, South China Sea islands, and the official dashed maritime claim line before implementing visual changes.
|
||||
- [ ] Earth AI command entry: merge natural-language and speech-triggered LLM commands into the existing Earth search panel as described in [Agent Runtime, Earth LLM Command, And Speech Entry Plan](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md).
|
||||
- [ ] Earth action executor: implement safe visualization actions for layer toggles, batch highlights, filters, focus, result panels, and clear-highlight behavior.
|
||||
- [ ] Earth entity matching: support stable entity ids and batch matching for Beidou satellites, mainland China compute centers, BGP, news, vessels, and cables.
|
||||
- [x] High-precision country boundary tile framework: implement the static vector tile builder, versioned seed output, frontend bbox tile loader, debounce, in-flight dedupe, and LRU cache described in [Earth High Precision Boundary Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-precision-boundary-tiles-plan.md).
|
||||
- [x] Add the `pmtiles-mvt` frontend tile provider contract, MVT decoder dependencies, static PMTiles Nginx handling, collector artifact registration, production readiness check, and user operation docs for Earth boundaries.
|
||||
- [x] Split Earth boundary ingestion into standard source collectors (`earth_admin0_boundaries`, `earth_coastline`, `earth_claim_lines`) plus the downstream `earth_boundary_tiles` PMTiles builder.
|
||||
- [ ] Replace debug GeoJSON boundary tiles with the real `earth-boundaries-china-pov-v1.pmtiles` production artifact after audited admin-0 / coastline / claim-line sources and the PMTiles toolchain are available.
|
||||
- [ ] Import authoritative China POV / coastline / claim-line source packages through the three standard Earth boundary source collectors, then rebuild a versioned PMTiles artifact so highest zoom `8-10` preserves trusted source geometry instead of seed data.
|
||||
- [ ] Earth boundary data: acquire or generate auditable China POV geometry for Zangnan, Aksai Chin, Taiwan/Penghu, Diaoyu Dao and affiliated islands, Chiwei Yu, South China Sea islands, Kosovo, Gaza, and the official dashed maritime claim line before implementing final visual changes.
|
||||
- [ ] Earth high-resolution basemap tiles: implement the viewport-loaded imagery layer described in [Earth High Resolution Basemap Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-resolution-basemap-tiles-plan.md), using high-precision coastline as the alignment reference instead of replacing the globe with one huge texture.
|
||||
- [ ] Presentation controller ownership: replace the singleton card fallback in [presentation-controller.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/presentation-controller.js) with a presentation/card token check before BGP/News migrate onto the shared controller, so connectors only attach to their owning card.
|
||||
- [ ] BGP frontend maintainability: split [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by responsibility into data loading, marker rendering, overlays, and animation once the current interaction behavior is stable.
|
||||
- [ ] Optional BGP marker experiment: evaluate HTML markers for BGP incident/collector points if WebGL marker density or fixed screen-size clickability becomes a real blocker.
|
||||
@@ -27,6 +35,10 @@ This file is the active backlog only. Completed history belongs in `docs/CHANGEL
|
||||
|
||||
## AI Provider And Agents
|
||||
|
||||
- [ ] Agent runtime foundation: add auditable agent runs, steps, evidence, proposals, and the Agent operations UI described in [Agent Runtime, Earth LLM Command, And Speech Entry Plan](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md).
|
||||
- [ ] Agent tool protocol: add backend JSON tool-call fallback, optional provider-native tool compatibility, tool whitelist validation, and policy-gated proposal application.
|
||||
- [ ] Speech/ASR integration for agents: add provider-neutral transcription settings and API, defaulting to Whisper-compatible API providers while keeping text commands usable when ASR is unavailable.
|
||||
- [ ] Earth voice wake: add device-local configurable wake-word preferences, microphone fallback states, and post-wake instruction upload for Earth commands.
|
||||
- [ ] AI provider compatibility center: move provider/model compatibility rules into a JSON/YAML config read by runtime, instead of continuing to scatter provider-specific branches through Python code.
|
||||
- [ ] Provider compatibility coverage: add explicit config for OpenAI, Anthropic, MiniMax, Ollama, Moonshot, DeepSeek, Qwen, GLM, Gemini, OpenRouter, vLLM, LM Studio, and One API.
|
||||
- [ ] Compatibility schema: cover adapter type, base URL pattern, auth header, thinking/reasoning defaults, stream path, tool-call capability, multimodal capability, and provider-specific request patches.
|
||||
|
||||
@@ -17,9 +17,6 @@ class Settings(BaseSettings):
|
||||
AI_HTTP_RETRY_ATTEMPTS: int = 2
|
||||
AI_MAX_TOKENS: int = 1200
|
||||
AI_ANTHROPIC_VERSION: str = "2023-06-01"
|
||||
AI_ANALYSIS_SYSTEM_PROMPT: str = (
|
||||
"你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。"
|
||||
)
|
||||
|
||||
AI_PROVIDER_SERVICE_TOKEN: str = ""
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ class ProviderService:
|
||||
self.anthropic_version = str(
|
||||
overrides.get("anthropic_version") or settings.AI_ANTHROPIC_VERSION
|
||||
)
|
||||
self.system_prompt = settings.AI_ANALYSIS_SYSTEM_PROMPT
|
||||
|
||||
def get_status(self) -> AIProviderStatusResponse:
|
||||
enabled = self.provider != "disabled"
|
||||
@@ -95,15 +94,20 @@ class ProviderService:
|
||||
prompt = self._build_prompt(payload)
|
||||
|
||||
if self.provider_api == "openai-completions":
|
||||
data = await self._request_openai_compatible(model, prompt)
|
||||
data = await self._request_openai_compatible(model, prompt, payload.system_prompt)
|
||||
content = self._extract_openai_content(data)
|
||||
content_blocks = self._extract_openai_blocks(data)
|
||||
elif self.provider_api == "anthropic-messages":
|
||||
data = await self._request_anthropic_messages(model, prompt, payload.thinking)
|
||||
data = await self._request_anthropic_messages(
|
||||
model,
|
||||
prompt,
|
||||
payload.thinking,
|
||||
payload.system_prompt,
|
||||
)
|
||||
content = self._extract_anthropic_content(data)
|
||||
content_blocks = self._extract_anthropic_blocks(data)
|
||||
elif self.provider_api == "ollama-generate":
|
||||
data = await self._request_ollama(model, prompt)
|
||||
data = await self._request_ollama(model, prompt, payload.system_prompt)
|
||||
content = self._extract_ollama_content(data)
|
||||
content_blocks = self._extract_ollama_blocks(data)
|
||||
else:
|
||||
@@ -139,19 +143,28 @@ class ProviderService:
|
||||
sections.append("约束条件:\n" + "\n".join(f"- {item}" for item in payload.constraints))
|
||||
if payload.context:
|
||||
sections.append(f"附加上下文:\n{payload.context}")
|
||||
sections.append(
|
||||
"请输出: 1) 态势摘要 2) 关键风险 3) 研判依据 4) 建议动作 5) 还缺少的数据。"
|
||||
)
|
||||
return "\n\n".join(sections)
|
||||
|
||||
async def _request_openai_compatible(self, model: str, prompt: str) -> dict[str, Any]:
|
||||
def _resolve_system_prompt(self, system_prompt: str | None) -> str | None:
|
||||
resolved = str(system_prompt or "").strip()
|
||||
return resolved or None
|
||||
|
||||
async def _request_openai_compatible(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
system_prompt: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
messages = []
|
||||
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
|
||||
if resolved_system_prompt:
|
||||
messages.append({"role": "system", "content": resolved_system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
request_body = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": self.system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"messages": messages,
|
||||
"temperature": 0.2,
|
||||
"max_tokens": self.max_tokens,
|
||||
}
|
||||
return await self._post(
|
||||
path="/chat/completions",
|
||||
@@ -167,10 +180,10 @@ class ProviderService:
|
||||
model: str,
|
||||
prompt: str,
|
||||
thinking: dict[str, Any] | None = None,
|
||||
system_prompt: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
request_body = {
|
||||
"model": model,
|
||||
"system": self.system_prompt,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -185,6 +198,9 @@ class ProviderService:
|
||||
"max_tokens": self.max_tokens,
|
||||
"temperature": 0.2,
|
||||
}
|
||||
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
|
||||
if resolved_system_prompt:
|
||||
request_body["system"] = resolved_system_prompt
|
||||
resolved_thinking = self._resolve_anthropic_thinking(thinking)
|
||||
if resolved_thinking:
|
||||
request_body["thinking"] = resolved_thinking
|
||||
@@ -218,19 +234,28 @@ class ProviderService:
|
||||
model: str,
|
||||
prompt: str,
|
||||
thinking: dict[str, Any] | None = None,
|
||||
system_prompt: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self._request_anthropic_messages(model, prompt, thinking)
|
||||
return await self._request_anthropic_messages(model, prompt, thinking, system_prompt)
|
||||
|
||||
async def _request_ollama(self, model: str, prompt: str) -> dict[str, Any]:
|
||||
async def _request_ollama(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
system_prompt: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
request_body = {
|
||||
"model": model,
|
||||
"stream": False,
|
||||
"system": self.system_prompt,
|
||||
"prompt": prompt,
|
||||
"options": {
|
||||
"temperature": 0.2,
|
||||
"num_predict": self.max_tokens,
|
||||
},
|
||||
}
|
||||
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
|
||||
if resolved_system_prompt:
|
||||
request_body["system"] = resolved_system_prompt
|
||||
return await self._post(
|
||||
path="/api/generate",
|
||||
headers={
|
||||
|
||||
@@ -13,10 +13,11 @@ class AIContentBlock(BaseModel):
|
||||
|
||||
class SituationalAnalysisRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
objective: str = Field(..., min_length=1, max_length=1000)
|
||||
objective: str = Field(..., min_length=1, max_length=20000)
|
||||
context: dict[str, Any] = Field(default_factory=dict)
|
||||
observations: list[str] = Field(default_factory=list)
|
||||
constraints: list[str] = Field(default_factory=list)
|
||||
system_prompt: str | None = Field(default=None, max_length=8000)
|
||||
preferred_model: str | None = Field(default=None, max_length=200)
|
||||
thinking: dict[str, Any] | None = None
|
||||
|
||||
|
||||
2
backend/app/ai_tasks/__init__.py
Normal file
2
backend/app/ai_tasks/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""AI task prompt registry and runtime helpers."""
|
||||
|
||||
74
backend/app/ai_tasks/default_prompts.json
Normal file
74
backend/app/ai_tasks/default_prompts.json
Normal file
@@ -0,0 +1,74 @@
|
||||
[
|
||||
{
|
||||
"key": "earth.news.enrich",
|
||||
"label": "Earth 新闻汉化与定位",
|
||||
"group": "Earth 新闻",
|
||||
"version": "2026-05-16.2",
|
||||
"system_prompt": "",
|
||||
"prompt": "Return exactly one strict JSON object with a location object and a localizations object. Infer the most likely physical event location and produce a faithful Simplified Chinese title plus a one-sentence newswire-style Chinese summary based only on the supplied RSS headline, description, source, and date. The summary should read like a concise breaking-news lead, not a label, slogan, or keyword headline."
|
||||
},
|
||||
{
|
||||
"key": "alerts.brief",
|
||||
"label": "系统告警研判",
|
||||
"group": "告警研判",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "你是告警研判助手。请基于输入的告警事实、上下文与约束,输出结构化、克制、可执行的值班研判;明确区分事实、推断与建议,不要夸大证据不足的风险。",
|
||||
"prompt": "基于当前告警总量、严重度、状态、数据源分布与最近告警摘录,生成一份面向值班人员的简明告警态势简报,突出待处理风险、告警集中点和优先动作。"
|
||||
},
|
||||
{
|
||||
"key": "alerts.situational.brief",
|
||||
"label": "跨模块态势告警研判",
|
||||
"group": "告警研判",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "你是告警研判助手。请基于输入的告警事实、上下文与约束,输出结构化、克制、可执行的值班研判;明确区分事实、推断与建议,不要夸大证据不足的风险。",
|
||||
"prompt": "综合系统告警、BGP incidents、BGP anomalies 与近期 BGP AI 简报,生成一份面向值班人员的态势告警简报,指出当前最需要关注的风险域、跨模块联动迹象和优先动作。"
|
||||
},
|
||||
{
|
||||
"key": "bgp.brief",
|
||||
"label": "BGP 态势简报",
|
||||
"group": "BGP",
|
||||
"version": "2026-05-16.2",
|
||||
"system_prompt": "你是 BGP 值班分析师。请直接输出面向值班人员的中文 Markdown 简报,只写最终研判内容;不要复述用户需求、提示词、写作计划、字段清单或“我将如何回答”。",
|
||||
"prompt": "基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。"
|
||||
},
|
||||
{
|
||||
"key": "location.factcheck.normalize",
|
||||
"label": "位置事实核查结构化",
|
||||
"group": "位置解析",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "Convert the supplied location factcheck text into exactly one strict JSON object. Extract only facts present in the text or original query."
|
||||
},
|
||||
{
|
||||
"key": "location.factcheck.resolve",
|
||||
"label": "位置事实核查兜底",
|
||||
"group": "位置解析",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "Return exactly one JSON object for the most likely physical location. Use only fact-checkable public knowledge; return null fields rather than guessing when evidence is weak."
|
||||
},
|
||||
{
|
||||
"key": "datasource.mapping",
|
||||
"label": "数据源映射生成",
|
||||
"group": "采集配置",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "Return only JSON for a deterministic mapping DSL. The JSON must contain source.items_path and fields. Do not include prose or code."
|
||||
},
|
||||
{
|
||||
"key": "credential.guide",
|
||||
"label": "采集器凭据教程",
|
||||
"group": "采集配置",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "生成一份中文采集器凭据配置教程。只能根据 context.search_evidence 中的来源生成教程;如果证据不足,明确说明需要以官方页面为准。"
|
||||
},
|
||||
{
|
||||
"key": "ai.connection_test",
|
||||
"label": "AI Provider 连接测试",
|
||||
"group": "运维测试",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "Reply OK."
|
||||
}
|
||||
]
|
||||
182
backend/app/ai_tasks/prompts.py
Normal file
182
backend/app/ai_tasks/prompts.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.system_setting import SystemSetting
|
||||
|
||||
AI_PROMPTS_CATEGORY = "ai_prompts"
|
||||
DEFAULT_PROMPTS_PATH = Path(__file__).with_name("default_prompts.json")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AIPromptDefinition:
|
||||
key: str
|
||||
label: str
|
||||
group: str
|
||||
version: str
|
||||
system_prompt: str
|
||||
prompt: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EffectiveAIPrompt:
|
||||
key: str
|
||||
label: str
|
||||
group: str
|
||||
version: str
|
||||
default_system_prompt: str
|
||||
default_prompt: str
|
||||
system_prompt: str
|
||||
prompt: str
|
||||
is_custom: bool
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def list_prompt_definitions() -> tuple[AIPromptDefinition, ...]:
|
||||
raw_items = json.loads(DEFAULT_PROMPTS_PATH.read_text(encoding="utf-8"))
|
||||
return tuple(
|
||||
AIPromptDefinition(
|
||||
key=str(item["key"]),
|
||||
label=str(item["label"]),
|
||||
group=str(item["group"]),
|
||||
version=str(item["version"]),
|
||||
system_prompt=str(item.get("system_prompt") or ""),
|
||||
prompt=str(item.get("prompt") or ""),
|
||||
)
|
||||
for item in raw_items
|
||||
)
|
||||
|
||||
|
||||
def get_prompt_definition(task_key: str) -> AIPromptDefinition:
|
||||
for definition in list_prompt_definitions():
|
||||
if definition.key == task_key:
|
||||
return definition
|
||||
raise KeyError(task_key)
|
||||
|
||||
|
||||
async def _get_prompt_setting(db: AsyncSession) -> SystemSetting | None:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.category == AI_PROMPTS_CATEGORY)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _normalize_overrides(payload: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
|
||||
raw = (payload or {}).get("overrides")
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
return {
|
||||
str(key): dict(value)
|
||||
for key, value in raw.items()
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
|
||||
|
||||
async def get_prompt_overrides(db: AsyncSession) -> dict[str, dict[str, Any]]:
|
||||
if not hasattr(db, "execute"):
|
||||
return {}
|
||||
setting = await _get_prompt_setting(db)
|
||||
return _normalize_overrides(setting.payload if setting else None)
|
||||
|
||||
|
||||
def _effective_prompt(
|
||||
definition: AIPromptDefinition,
|
||||
override: dict[str, Any] | None,
|
||||
) -> EffectiveAIPrompt:
|
||||
override = override or {}
|
||||
custom_system = override.get("system_prompt")
|
||||
custom_prompt = override.get("prompt")
|
||||
has_custom_system = isinstance(custom_system, str)
|
||||
has_custom_prompt = isinstance(custom_prompt, str)
|
||||
return EffectiveAIPrompt(
|
||||
key=definition.key,
|
||||
label=definition.label,
|
||||
group=definition.group,
|
||||
version=definition.version,
|
||||
default_system_prompt=definition.system_prompt,
|
||||
default_prompt=definition.prompt,
|
||||
system_prompt=custom_system if has_custom_system else definition.system_prompt,
|
||||
prompt=custom_prompt if has_custom_prompt else definition.prompt,
|
||||
is_custom=has_custom_system or has_custom_prompt,
|
||||
updated_at=str(override.get("updated_at") or "") or None,
|
||||
)
|
||||
|
||||
|
||||
async def list_effective_prompts(db: AsyncSession) -> list[EffectiveAIPrompt]:
|
||||
overrides = await get_prompt_overrides(db)
|
||||
return [
|
||||
_effective_prompt(definition, overrides.get(definition.key))
|
||||
for definition in list_prompt_definitions()
|
||||
]
|
||||
|
||||
|
||||
async def get_effective_prompt(db: AsyncSession | None, task_key: str) -> EffectiveAIPrompt:
|
||||
definition = get_prompt_definition(task_key)
|
||||
if db is None:
|
||||
return _effective_prompt(definition, None)
|
||||
overrides = await get_prompt_overrides(db)
|
||||
return _effective_prompt(definition, overrides.get(task_key))
|
||||
|
||||
|
||||
async def save_prompt_override(
|
||||
db: AsyncSession,
|
||||
task_key: str,
|
||||
*,
|
||||
system_prompt: str,
|
||||
prompt: str,
|
||||
) -> EffectiveAIPrompt:
|
||||
definition = get_prompt_definition(task_key)
|
||||
setting = await _get_prompt_setting(db)
|
||||
payload = dict(setting.payload or {}) if setting else {}
|
||||
overrides = _normalize_overrides(payload)
|
||||
overrides[definition.key] = {
|
||||
"system_prompt": system_prompt,
|
||||
"prompt": prompt,
|
||||
"updated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
payload["overrides"] = overrides
|
||||
if setting is None:
|
||||
setting = SystemSetting(category=AI_PROMPTS_CATEGORY, payload=payload)
|
||||
db.add(setting)
|
||||
else:
|
||||
setting.payload = payload
|
||||
await db.commit()
|
||||
return _effective_prompt(definition, overrides[definition.key])
|
||||
|
||||
|
||||
async def reset_prompt_override(db: AsyncSession, task_key: str) -> EffectiveAIPrompt:
|
||||
definition = get_prompt_definition(task_key)
|
||||
setting = await _get_prompt_setting(db)
|
||||
if setting is None:
|
||||
return _effective_prompt(definition, None)
|
||||
payload = dict(setting.payload or {})
|
||||
overrides = _normalize_overrides(payload)
|
||||
overrides.pop(definition.key, None)
|
||||
payload["overrides"] = overrides
|
||||
setting.payload = payload
|
||||
await db.commit()
|
||||
return _effective_prompt(definition, None)
|
||||
|
||||
|
||||
def serialize_effective_prompt(prompt: EffectiveAIPrompt) -> dict[str, Any]:
|
||||
return {
|
||||
"key": prompt.key,
|
||||
"label": prompt.label,
|
||||
"group": prompt.group,
|
||||
"version": prompt.version,
|
||||
"default_system_prompt": prompt.default_system_prompt,
|
||||
"default_prompt": prompt.default_prompt,
|
||||
"system_prompt": prompt.system_prompt,
|
||||
"prompt": prompt.prompt,
|
||||
"is_custom": prompt.is_custom,
|
||||
"updated_at": prompt.updated_at,
|
||||
}
|
||||
@@ -6,6 +6,7 @@ from app.api.v1 import (
|
||||
datasource_config,
|
||||
datasources,
|
||||
docs,
|
||||
earth,
|
||||
tasks,
|
||||
dashboard,
|
||||
alerts,
|
||||
@@ -34,6 +35,7 @@ api_router.include_router(
|
||||
api_router.include_router(datasources.router, prefix="/datasources", tags=["datasources"])
|
||||
api_router.include_router(collected_data.router, prefix="/collected", tags=["collected-data"])
|
||||
api_router.include_router(docs.router, prefix="/docs", tags=["docs"])
|
||||
api_router.include_router(earth.router, prefix="/earth", tags=["earth"])
|
||||
api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
|
||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
||||
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
||||
|
||||
@@ -341,6 +341,7 @@ async def collect_bgp_collector_location(
|
||||
provider_client=provider_client,
|
||||
query=query,
|
||||
entity_type="bgp_collector",
|
||||
db=db,
|
||||
attempted_queries=attempted_queries,
|
||||
search_evidence=search_result.evidence,
|
||||
)
|
||||
|
||||
@@ -102,7 +102,12 @@ def build_search_rank_sql(search: Optional[str]) -> str:
|
||||
"""
|
||||
|
||||
|
||||
def serialize_collected_row(row, source_name_map: dict[str, str] | None = None) -> dict:
|
||||
def serialize_collected_row(
|
||||
row,
|
||||
source_name_map: dict[str, str] | None = None,
|
||||
*,
|
||||
include_metadata: bool = True,
|
||||
) -> dict:
|
||||
metadata = row[7]
|
||||
source = row[1]
|
||||
return {
|
||||
@@ -120,7 +125,7 @@ def serialize_collected_row(row, source_name_map: dict[str, str] | None = None)
|
||||
"longitude": get_metadata_field(metadata, "longitude"),
|
||||
"value": get_metadata_field(metadata, "value"),
|
||||
"unit": get_metadata_field(metadata, "unit"),
|
||||
"metadata": metadata,
|
||||
"metadata": metadata if include_metadata else None,
|
||||
"cores": get_metadata_field(metadata, "cores"),
|
||||
"rmax": get_metadata_field(metadata, "rmax"),
|
||||
"rpeak": get_metadata_field(metadata, "rpeak"),
|
||||
@@ -145,6 +150,7 @@ async def list_collected_data(
|
||||
search: Optional[str] = Query(None, description="搜索名称"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
include_metadata: bool = Query(True, description="是否返回完整 metadata 字段"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -201,7 +207,7 @@ async def list_collected_data(
|
||||
|
||||
data = []
|
||||
for row in rows:
|
||||
data.append(serialize_collected_row(row[:11], source_name_map))
|
||||
data.append(serialize_collected_row(row[:11], source_name_map, include_metadata=include_metadata))
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.models.vessel import AISRawObservation, AISSourceHealth
|
||||
from app.core.security import get_current_user
|
||||
from app.core.cache import cache
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.services.ai_client import AIProviderClient, get_ai_provider_client
|
||||
from app.services.datasource_mapping import (
|
||||
@@ -41,6 +42,8 @@ from app.services.custom_datasource_runtime import (
|
||||
stop_custom_stream,
|
||||
test_websocket_config,
|
||||
)
|
||||
|
||||
DATASOURCE_MAPPING_PROMPT_KEY = "datasource.mapping"
|
||||
from app.services.datasource_connectivity import (
|
||||
get_builtin_connection_status,
|
||||
save_connectivity_success,
|
||||
@@ -50,6 +53,15 @@ from app.services.datasource_connectivity import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def _default_builtin_config(name: str) -> dict[str, Any]:
|
||||
return {"timeout": 30, "retry": 3}
|
||||
|
||||
|
||||
def _default_builtin_source_type(name: str) -> str:
|
||||
if name == "aisstream_vessels":
|
||||
return "websocket"
|
||||
return "http"
|
||||
|
||||
|
||||
class DataSourceConfigCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
@@ -364,7 +376,7 @@ async def list_all_datasources(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all data sources: YAML defaults + DB overrides"""
|
||||
from app.core.data_sources import COLLECTOR_URL_KEYS, get_data_sources_config
|
||||
from app.core.data_sources import get_data_sources_config
|
||||
|
||||
config = get_data_sources_config()
|
||||
|
||||
@@ -372,20 +384,22 @@ async def list_all_datasources(
|
||||
db_configs = {c.name: c for c in db_query.scalars().all()}
|
||||
|
||||
result = []
|
||||
for name, yaml_key in COLLECTOR_URL_KEYS.items():
|
||||
for name, metadata in DEFAULT_DATASOURCES.items():
|
||||
yaml_url = config.get_yaml_url(name)
|
||||
db_config = db_configs.get(name)
|
||||
default_config = _default_builtin_config(name)
|
||||
default_url = yaml_url
|
||||
|
||||
result.append(
|
||||
{
|
||||
"name": name,
|
||||
"default_url": yaml_url,
|
||||
"endpoint": db_config.endpoint if db_config else yaml_url,
|
||||
"default_url": default_url,
|
||||
"endpoint": db_config.endpoint if db_config else default_url,
|
||||
"is_overridden": db_config is not None and db_config.endpoint != yaml_url
|
||||
if yaml_url
|
||||
if default_url
|
||||
else db_config is not None,
|
||||
"is_active": db_config.is_active if db_config else True,
|
||||
"source_type": db_config.source_type if db_config else "http",
|
||||
"source_type": db_config.source_type if db_config else _default_builtin_source_type(name),
|
||||
"auth_type": db_config.auth_type if db_config else "none",
|
||||
"auth_configured": {
|
||||
"api_key": bool((db_config.auth_config or {}).get("api_key"))
|
||||
@@ -393,11 +407,11 @@ async def list_all_datasources(
|
||||
else False,
|
||||
},
|
||||
"headers": db_config.headers if db_config else {},
|
||||
"config": strip_connectivity_validation(db_config.config if db_config else {}),
|
||||
"config": strip_connectivity_validation(db_config.config if db_config else default_config),
|
||||
"config_id": db_config.id if db_config else None,
|
||||
"description": db_config.description
|
||||
if db_config
|
||||
else f"Data source from YAML: {yaml_key}",
|
||||
else f"内置采集器默认配置:{metadata.get('display_name') or metadata.get('name') or name}",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -757,14 +771,12 @@ async def propose_datasource_mapping(
|
||||
generated_by = "heuristic"
|
||||
if payload.use_ai:
|
||||
try:
|
||||
prompt = await get_effective_prompt(db, DATASOURCE_MAPPING_PROMPT_KEY)
|
||||
response = await ai_client.analyze(
|
||||
SituationalAnalysisRequest(
|
||||
title=f"Generate datasource mapping for {schema.key}",
|
||||
objective=(
|
||||
"Return only JSON for a deterministic mapping DSL. "
|
||||
"The JSON must contain source.items_path and fields. "
|
||||
"Do not include prose or code."
|
||||
),
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
context={
|
||||
"target_schema": schema.to_dict(),
|
||||
"sample_payload": redacted_sample,
|
||||
|
||||
@@ -150,6 +150,33 @@ async def _load_latest_task_ids(
|
||||
return {datasource_id: task_id for datasource_id, task_id in result.all()}
|
||||
|
||||
|
||||
async def _load_latest_tasks(
|
||||
db: AsyncSession,
|
||||
datasource_ids: list[int],
|
||||
) -> dict[int, CollectionTask]:
|
||||
if not datasource_ids:
|
||||
return {}
|
||||
|
||||
ranked_tasks = (
|
||||
select(
|
||||
CollectionTask.id.label("task_id"),
|
||||
CollectionTask.datasource_id.label("datasource_id"),
|
||||
func.row_number().over(
|
||||
partition_by=CollectionTask.datasource_id,
|
||||
order_by=(CollectionTask.created_at.desc().nullslast(), CollectionTask.id.desc()),
|
||||
).label("row_num"),
|
||||
)
|
||||
.where(CollectionTask.datasource_id.in_(datasource_ids))
|
||||
.subquery()
|
||||
)
|
||||
result = await db.execute(
|
||||
select(CollectionTask)
|
||||
.join(ranked_tasks, CollectionTask.id == ranked_tasks.c.task_id)
|
||||
.where(ranked_tasks.c.row_num == 1)
|
||||
)
|
||||
return {task.datasource_id: task for task in result.scalars().all()}
|
||||
|
||||
|
||||
async def _load_collected_record_counts(
|
||||
db: AsyncSession,
|
||||
sources: list[str],
|
||||
@@ -208,7 +235,7 @@ async def _load_datasource_endpoint_overrides(
|
||||
async def _load_datasource_list_context(
|
||||
db: AsyncSession,
|
||||
datasources: list[DataSource],
|
||||
) -> tuple[dict[int, CollectionTask], dict[str, str]]:
|
||||
) -> tuple[dict[int, CollectionTask], dict[int, CollectionTask], dict[str, str]]:
|
||||
datasource_ids = [datasource.id for datasource in datasources]
|
||||
sources = [datasource.source for datasource in datasources]
|
||||
|
||||
@@ -232,8 +259,9 @@ async def _load_datasource_list_context(
|
||||
if stale_datasource_ids:
|
||||
running_tasks = await _load_latest_running_tasks(db, datasource_ids)
|
||||
|
||||
latest_tasks = await _load_latest_tasks(db, datasource_ids)
|
||||
endpoint_overrides = await _load_datasource_endpoint_overrides(db, sources)
|
||||
return running_tasks, endpoint_overrides
|
||||
return running_tasks, latest_tasks, endpoint_overrides
|
||||
|
||||
|
||||
def _apply_datasource_query_filters(
|
||||
@@ -251,11 +279,6 @@ def _apply_datasource_query_filters(
|
||||
query = query.where(DataSource.is_active == is_active)
|
||||
if priority:
|
||||
query = query.where(DataSource.priority == priority)
|
||||
if run_status and run_status not in {"running", "collected", "uncollected"}:
|
||||
if run_status == "not_run":
|
||||
query = query.where(DataSource.last_status.is_(None))
|
||||
else:
|
||||
query = query.where(DataSource.last_status == run_status)
|
||||
if q:
|
||||
like_value = f"%{q.strip()}%"
|
||||
query = query.where(
|
||||
@@ -272,15 +295,25 @@ def _filter_datasources_in_memory(
|
||||
datasources: list[DataSource],
|
||||
*,
|
||||
running_tasks: dict[int, CollectionTask],
|
||||
latest_tasks: dict[int, CollectionTask] | None = None,
|
||||
record_counts: dict[str, int],
|
||||
product: Optional[str] = None,
|
||||
run_status: Optional[str] = None,
|
||||
collected: Optional[bool] = None,
|
||||
credential_status: Optional[str] = None,
|
||||
) -> list[DataSource]:
|
||||
latest_tasks = latest_tasks or {}
|
||||
filtered: list[DataSource] = []
|
||||
for datasource in datasources:
|
||||
record_count = record_counts.get(datasource.source, 0)
|
||||
latest_task = latest_tasks.get(datasource.id)
|
||||
effective_status = (
|
||||
"running"
|
||||
if datasource.id in running_tasks
|
||||
else latest_task.status
|
||||
if latest_task is not None
|
||||
else datasource.last_status
|
||||
)
|
||||
if product and datasource_product_key(datasource) != product:
|
||||
continue
|
||||
if collected is not None and (record_count > 0) != collected:
|
||||
@@ -291,6 +324,10 @@ def _filter_datasources_in_memory(
|
||||
continue
|
||||
if run_status == "running" and datasource.id not in running_tasks:
|
||||
continue
|
||||
if run_status == "not_run" and effective_status is not None:
|
||||
continue
|
||||
if run_status not in {None, "running", "not_run", "collected", "uncollected"} and effective_status != run_status:
|
||||
continue
|
||||
if run_status == "collected" and record_count <= 0:
|
||||
continue
|
||||
if run_status == "uncollected" and record_count > 0:
|
||||
@@ -639,11 +676,12 @@ async def list_datasources(
|
||||
|
||||
collector_list = []
|
||||
config = get_data_sources_config()
|
||||
running_tasks, endpoint_overrides = await _load_datasource_list_context(db, datasources)
|
||||
running_tasks, latest_tasks, endpoint_overrides = await _load_datasource_list_context(db, datasources)
|
||||
record_counts = await _load_collected_record_counts(db, [datasource.source for datasource in datasources])
|
||||
datasources = _filter_datasources_in_memory(
|
||||
datasources,
|
||||
running_tasks=running_tasks,
|
||||
latest_tasks=latest_tasks,
|
||||
record_counts=record_counts,
|
||||
product=product,
|
||||
run_status=run_status,
|
||||
@@ -652,9 +690,11 @@ async def list_datasources(
|
||||
)
|
||||
for datasource in datasources:
|
||||
running_task = running_tasks.get(datasource.id)
|
||||
latest_task = latest_tasks.get(datasource.id)
|
||||
display_task = running_task or latest_task
|
||||
endpoint = endpoint_overrides.get(datasource.source) or config.get_yaml_url(datasource.source)
|
||||
last_run_at = datasource.last_run_at
|
||||
last_status = datasource.last_status
|
||||
last_run_at = datasource.last_run_at or (latest_task.completed_at if latest_task else None)
|
||||
last_status = datasource.last_status or (latest_task.status if latest_task else None)
|
||||
collected_records = record_counts.get(datasource.source, 0)
|
||||
|
||||
collector_list.append(
|
||||
@@ -675,16 +715,17 @@ async def list_datasources(
|
||||
"last_run_at": to_iso8601_utc(last_run_at),
|
||||
"last_status": last_status,
|
||||
"is_running": running_task is not None,
|
||||
"task_id": running_task.id if running_task else None,
|
||||
"progress": running_task.progress if running_task else None,
|
||||
"phase": running_task.phase if running_task else None,
|
||||
"phase_progress": running_task.phase_progress if running_task else None,
|
||||
"phase_message": running_task.phase_message if running_task else None,
|
||||
"phase_current": running_task.phase_current if running_task else None,
|
||||
"phase_total": running_task.phase_total if running_task else None,
|
||||
"phase_unit": running_task.phase_unit if running_task else None,
|
||||
"records_processed": running_task.records_processed if running_task else None,
|
||||
"total_records": running_task.total_records if running_task else None,
|
||||
"task_id": display_task.id if display_task else None,
|
||||
"progress": display_task.progress if display_task else None,
|
||||
"phase": display_task.phase if display_task else None,
|
||||
"phase_progress": display_task.phase_progress if display_task else None,
|
||||
"phase_message": display_task.phase_message if display_task else None,
|
||||
"phase_current": display_task.phase_current if display_task else None,
|
||||
"phase_total": display_task.phase_total if display_task else None,
|
||||
"phase_unit": display_task.phase_unit if display_task else None,
|
||||
"records_processed": display_task.records_processed if display_task else None,
|
||||
"total_records": display_task.total_records if display_task else None,
|
||||
"error_message": display_task.error_message if display_task else None,
|
||||
"collected_records": collected_records,
|
||||
"has_collected_data": collected_records > 0,
|
||||
}
|
||||
@@ -729,11 +770,12 @@ async def trigger_datasource_batch(
|
||||
|
||||
result = await db.execute(query)
|
||||
datasources = result.scalars().all()
|
||||
running_tasks, _ = await _load_datasource_list_context(db, datasources)
|
||||
running_tasks, latest_tasks, _ = await _load_datasource_list_context(db, datasources)
|
||||
record_counts = await _load_collected_record_counts(db, [datasource.source for datasource in datasources])
|
||||
datasources = _filter_datasources_in_memory(
|
||||
datasources,
|
||||
running_tasks=running_tasks,
|
||||
latest_tasks=latest_tasks,
|
||||
record_counts=record_counts,
|
||||
product=None if payload.source_ids else payload.product,
|
||||
run_status=None if payload.source_ids else payload.run_status,
|
||||
@@ -935,6 +977,14 @@ async def get_task_status(
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
else:
|
||||
task = await get_running_task(db, datasource.id)
|
||||
if task is None:
|
||||
result = await db.execute(
|
||||
select(CollectionTask)
|
||||
.where(CollectionTask.datasource_id == datasource.id)
|
||||
.order_by(CollectionTask.created_at.desc().nullslast(), CollectionTask.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
return {
|
||||
@@ -963,4 +1013,5 @@ async def get_task_status(
|
||||
"records_processed": task.records_processed,
|
||||
"total_records": task.total_records,
|
||||
"status": task.status,
|
||||
"error_message": task.error_message,
|
||||
}
|
||||
|
||||
247
backend/app/api/v1/earth.py
Normal file
247
backend/app/api/v1/earth.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Earth asset management APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import decode_token, get_current_user, redis_client
|
||||
from app.db.session import get_db
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.user import User
|
||||
from app.services.earth_boundaries import (
|
||||
EarthBoundaryBuildError,
|
||||
get_boundary_build_status,
|
||||
get_boundary_status,
|
||||
save_boundary_config,
|
||||
start_boundary_build_job,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
optional_bearer = HTTPBearer(auto_error=False)
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
EARTH_BRAND_ASSET_DIR = REPO_ROOT / "data" / "earth-brand"
|
||||
EARTH_BRAND_ASSET_URL_PREFIX = "/earth-brand-assets"
|
||||
EARTH_BRAND_CATEGORY = "earth_brand"
|
||||
MAX_EARTH_BRAND_ASSET_BYTES = 3 * 1024 * 1024
|
||||
ALLOWED_EARTH_BRAND_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".svg"}
|
||||
|
||||
DEFAULT_EARTH_BRAND = {
|
||||
"logo_src": "/earth/assets/brand/earth-logo.png",
|
||||
"title_src": "/earth/assets/brand/title-zh.png",
|
||||
"title_text": "智能星球计划",
|
||||
"subtitle": "现实层宇宙全息感知系统",
|
||||
"description": "卫星 · 海底光缆 · 算力基础设施",
|
||||
"aria_label": "智能星球计划品牌标识",
|
||||
"title_alt": "智能星球计划",
|
||||
}
|
||||
|
||||
|
||||
class EarthBoundaryConfigPayload(BaseModel):
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class EarthBrandPayload(BaseModel):
|
||||
logo_src: str = Field(default=DEFAULT_EARTH_BRAND["logo_src"], max_length=1000)
|
||||
title_src: str = Field(default=DEFAULT_EARTH_BRAND["title_src"], max_length=1000)
|
||||
title_text: str = Field(default=DEFAULT_EARTH_BRAND["title_text"], max_length=120)
|
||||
subtitle: str = Field(default=DEFAULT_EARTH_BRAND["subtitle"], max_length=160)
|
||||
description: str = Field(default=DEFAULT_EARTH_BRAND["description"], max_length=200)
|
||||
aria_label: str = Field(default=DEFAULT_EARTH_BRAND["aria_label"], max_length=200)
|
||||
title_alt: str = Field(default=DEFAULT_EARTH_BRAND["title_alt"], max_length=200)
|
||||
|
||||
|
||||
def _normalize_earth_brand_payload(payload: dict[str, Any] | None) -> dict[str, str]:
|
||||
merged = DEFAULT_EARTH_BRAND.copy()
|
||||
if payload:
|
||||
for key in DEFAULT_EARTH_BRAND:
|
||||
value = payload.get(key)
|
||||
if value is not None:
|
||||
merged[key] = str(value).strip()
|
||||
|
||||
if not merged["title_text"]:
|
||||
merged["title_text"] = DEFAULT_EARTH_BRAND["title_text"]
|
||||
if not merged["aria_label"]:
|
||||
merged["aria_label"] = merged["title_text"]
|
||||
if not merged["title_alt"]:
|
||||
merged["title_alt"] = merged["title_text"]
|
||||
return merged
|
||||
|
||||
|
||||
async def _get_earth_brand_record(db: AsyncSession) -> SystemSetting | None:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.category == EARTH_BRAND_CATEGORY)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _get_earth_brand_payload(db: AsyncSession) -> dict[str, Any]:
|
||||
record = await _get_earth_brand_record(db)
|
||||
return {
|
||||
"brand": _normalize_earth_brand_payload(record.payload if record else None),
|
||||
"is_default": record is None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/brand")
|
||||
async def get_earth_brand(db: AsyncSession = Depends(get_db)):
|
||||
return await _get_earth_brand_payload(db)
|
||||
|
||||
|
||||
@router.put("/brand")
|
||||
async def update_earth_brand(
|
||||
payload: EarthBrandPayload,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
normalized = _normalize_earth_brand_payload(payload.model_dump())
|
||||
record = await _get_earth_brand_record(db)
|
||||
if record is None:
|
||||
record = SystemSetting(category=EARTH_BRAND_CATEGORY, payload=normalized)
|
||||
db.add(record)
|
||||
else:
|
||||
record.payload = normalized
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return {"status": "updated", "brand": _normalize_earth_brand_payload(record.payload), "is_default": False}
|
||||
|
||||
|
||||
@router.delete("/brand")
|
||||
@router.post("/brand/reset")
|
||||
async def reset_earth_brand(
|
||||
_current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await db.execute(delete(SystemSetting).where(SystemSetting.category == EARTH_BRAND_CATEGORY))
|
||||
await db.commit()
|
||||
return {"status": "reset", "brand": DEFAULT_EARTH_BRAND.copy(), "is_default": True}
|
||||
|
||||
|
||||
@router.post("/brand/assets")
|
||||
async def upload_earth_brand_asset(
|
||||
file: UploadFile = File(...),
|
||||
_current_user: User = Depends(get_current_user),
|
||||
):
|
||||
original_name = file.filename or ""
|
||||
extension = Path(original_name).suffix.lower()
|
||||
if extension not in ALLOWED_EARTH_BRAND_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "unsupported_file_type",
|
||||
"message": "Only png, jpg, jpeg, webp, and svg brand assets are supported.",
|
||||
},
|
||||
)
|
||||
|
||||
content = await file.read(MAX_EARTH_BRAND_ASSET_BYTES + 1)
|
||||
if len(content) > MAX_EARTH_BRAND_ASSET_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "file_too_large",
|
||||
"message": "Brand asset must be 3 MB or smaller.",
|
||||
},
|
||||
)
|
||||
|
||||
EARTH_BRAND_ASSET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = f"{uuid4().hex}{extension}"
|
||||
destination = EARTH_BRAND_ASSET_DIR / safe_name
|
||||
destination.write_bytes(content)
|
||||
asset_url = f"{EARTH_BRAND_ASSET_URL_PREFIX}/{safe_name}"
|
||||
return {"url": asset_url, "filename": safe_name, "content_type": file.content_type}
|
||||
|
||||
|
||||
@router.get("/boundaries/status")
|
||||
async def get_earth_boundary_status():
|
||||
return get_boundary_status()
|
||||
|
||||
|
||||
async def _get_optional_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User | None:
|
||||
if credentials is None:
|
||||
return None
|
||||
token = credentials.credentials
|
||||
if redis_client.sismember("blacklisted_tokens", token):
|
||||
return None
|
||||
payload = decode_token(token)
|
||||
if payload is None or payload.get("type") != "access":
|
||||
return None
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
return None
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": int(user_id)},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row is None or not row[5]:
|
||||
return None
|
||||
user = User()
|
||||
user.id = row[0]
|
||||
user.username = row[1]
|
||||
user.email = row[2]
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
return user
|
||||
|
||||
|
||||
def _is_loopback_request(request: Request) -> bool:
|
||||
host = request.client.host if request.client else ""
|
||||
return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.")
|
||||
|
||||
|
||||
def _require_local_or_user(request: Request, user: User | None) -> None:
|
||||
if user is not None or _is_loopback_request(request):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required outside localhost",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/boundaries/config")
|
||||
async def update_earth_boundary_config(
|
||||
payload: EarthBoundaryConfigPayload,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
):
|
||||
try:
|
||||
return save_boundary_config(payload.config)
|
||||
except EarthBoundaryBuildError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"code": exc.code, "message": str(exc), "details": exc.details},
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/boundaries/build")
|
||||
async def build_earth_boundary_assets(
|
||||
request: Request,
|
||||
current_user: User | None = Depends(_get_optional_current_user),
|
||||
):
|
||||
_require_local_or_user(request, current_user)
|
||||
try:
|
||||
return await start_boundary_build_job()
|
||||
except EarthBoundaryBuildError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"code": exc.code, "message": str(exc), "details": exc.details},
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get("/boundaries/build/status")
|
||||
async def get_earth_boundary_build_status():
|
||||
return get_boundary_build_status()
|
||||
@@ -1,5 +1,7 @@
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.services.earth_news import get_earth_news_payload
|
||||
|
||||
router = APIRouter()
|
||||
@@ -9,5 +11,6 @@ router = APIRouter()
|
||||
async def get_earth_feed(
|
||||
lat: float | None = Query(None, description="Current Earth view center latitude"),
|
||||
lon: float | None = Query(None, description="Current Earth view center longitude"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await get_earth_news_payload(lat=lat, lon=lon)
|
||||
return await get_earth_news_payload(lat=lat, lon=lon, db=db)
|
||||
|
||||
@@ -15,6 +15,13 @@ 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.ai_tasks.prompts import (
|
||||
get_effective_prompt,
|
||||
list_effective_prompts,
|
||||
reset_prompt_override,
|
||||
save_prompt_override,
|
||||
serialize_effective_prompt,
|
||||
)
|
||||
from app.db.session import get_db
|
||||
from app.models.datasource import DataSource
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
@@ -59,6 +66,8 @@ 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()
|
||||
AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS = 5
|
||||
AI_CONNECTION_TEST_PROMPT_KEY = "ai.connection_test"
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"system": {
|
||||
@@ -250,6 +259,11 @@ class OCRIntegrationUpdate(BaseModel):
|
||||
output_format: str = Field(default="markdown", pattern="^(markdown|text|json)$")
|
||||
|
||||
|
||||
class AIPromptUpdate(BaseModel):
|
||||
system_prompt: str = Field(default="", max_length=8000)
|
||||
prompt: str = Field(min_length=1, max_length=20000)
|
||||
|
||||
|
||||
class ExternalIntegrationsUpdate(BaseModel):
|
||||
ai_provider: AIProviderIntegrationUpdate
|
||||
barentswatch: BarentsWatchIntegrationUpdate
|
||||
@@ -484,7 +498,13 @@ def _is_secret_placeholder(value: Optional[str], current_preview: str = "") -> b
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return True
|
||||
return text == current_preview or text.startswith("••••") or "*" in text
|
||||
if text == current_preview or text.startswith("••••"):
|
||||
return True
|
||||
if "-" in text:
|
||||
_prefix, masked = text.split("-", 1)
|
||||
if masked and all(char in {"*", "•", " ", "\t"} for char in masked):
|
||||
return True
|
||||
return all(char in {"*", "•", " ", "\t"} for char in text)
|
||||
|
||||
|
||||
def _build_ai_provider_payload(current_payload: dict, update: AIProviderIntegrationUpdate) -> dict:
|
||||
@@ -561,6 +581,56 @@ def _runtime_config_from_ai_payload(ai_payload: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _ai_provider_runtime_fingerprint(ai_payload: dict) -> dict:
|
||||
runtime_config = _runtime_config_from_ai_payload(ai_payload)
|
||||
llm_config = runtime_config.get("llm_config") or {}
|
||||
return {
|
||||
"service_url": runtime_config.get("service_url") or "",
|
||||
"service_token": runtime_config.get("service_token") or "",
|
||||
"timeout_seconds": int(runtime_config.get("timeout_seconds") or 0),
|
||||
"retry_attempts": int(runtime_config.get("retry_attempts") or 0),
|
||||
"provider": llm_config.get("provider") or "",
|
||||
"provider_api": llm_config.get("provider_api") or "",
|
||||
"base_url": llm_config.get("base_url") or "",
|
||||
"model": llm_config.get("model") or "",
|
||||
"api_key": llm_config.get("api_key") or "",
|
||||
"max_tokens": int(llm_config.get("max_tokens") or 0),
|
||||
"anthropic_version": llm_config.get("anthropic_version") or "",
|
||||
}
|
||||
|
||||
|
||||
async def _validate_ai_provider_full_connection(ai_payload: dict) -> dict:
|
||||
runtime_config = _runtime_config_from_ai_payload(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 {},
|
||||
)
|
||||
status_result = await client.get_status()
|
||||
if not status_result.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="AI Provider 可访问,但当前 provider/model/key 未完整配置。",
|
||||
)
|
||||
prompt = await get_effective_prompt(None, AI_CONNECTION_TEST_PROMPT_KEY)
|
||||
analysis_result = await client.analyze(
|
||||
SituationalAnalysisRequest(
|
||||
title="保存前完整连接测试",
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
observations=["这是保存 AI Provider 配置前的完整 LLM 调用测试。"],
|
||||
constraints=["回复尽量简短。"],
|
||||
)
|
||||
)
|
||||
return {
|
||||
"status": status_result.model_dump(),
|
||||
"provider": analysis_result.provider,
|
||||
"model": analysis_result.model,
|
||||
}
|
||||
|
||||
|
||||
def _web_search_provider_defaults(provider: str) -> dict:
|
||||
return web_search_provider_defaults(provider).model_dump()
|
||||
|
||||
@@ -910,7 +980,10 @@ async def save_external_integrations_payload(
|
||||
update: ExternalIntegrationsUpdate,
|
||||
) -> dict:
|
||||
current_payload = await get_setting_payload(db, "external_integrations")
|
||||
current_ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
|
||||
ai_payload = _build_ai_provider_payload(current_payload, update.ai_provider)
|
||||
if _ai_provider_runtime_fingerprint(ai_payload) != _ai_provider_runtime_fingerprint(current_ai_payload):
|
||||
await _validate_ai_provider_full_connection(ai_payload)
|
||||
web_search_payload = _build_web_search_payload(current_payload, update.web_search)
|
||||
ocr_payload = _build_ocr_payload(current_payload, update.ocr)
|
||||
|
||||
@@ -1158,6 +1231,47 @@ async def get_external_integrations(
|
||||
return {"integrations": await serialize_external_integrations(db)}
|
||||
|
||||
|
||||
@router.get("/ai-prompts")
|
||||
async def get_ai_prompts(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
prompts = await list_effective_prompts(db)
|
||||
return {"data": [serialize_effective_prompt(prompt) for prompt in prompts]}
|
||||
|
||||
|
||||
@router.put("/ai-prompts/{task_key}")
|
||||
async def update_ai_prompt(
|
||||
task_key: str,
|
||||
payload: AIPromptUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
prompt = await save_prompt_override(
|
||||
db,
|
||||
task_key,
|
||||
system_prompt=payload.system_prompt,
|
||||
prompt=payload.prompt,
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="未知 AI 提示词任务") from None
|
||||
return {"data": serialize_effective_prompt(prompt)}
|
||||
|
||||
|
||||
@router.post("/ai-prompts/{task_key}/reset")
|
||||
async def reset_ai_prompt(
|
||||
task_key: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
prompt = await reset_prompt_override(db, task_key)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="未知 AI 提示词任务") from None
|
||||
return {"data": serialize_effective_prompt(prompt)}
|
||||
|
||||
|
||||
@router.get("/integrations/barentswatch/connectivity")
|
||||
async def get_barentswatch_connectivity(
|
||||
current_user: User = Depends(get_current_user),
|
||||
@@ -1219,12 +1333,16 @@ async def connect_ai_provider_integration(
|
||||
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)
|
||||
quick_llm_config = {
|
||||
**(runtime_config.get("llm_config") or {}),
|
||||
"max_tokens": 1,
|
||||
}
|
||||
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 {},
|
||||
timeout=min(int(runtime_config["timeout_seconds"] or 60), AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS),
|
||||
retry_attempts=1,
|
||||
llm_config=quick_llm_config,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -1236,29 +1354,24 @@ async def connect_ai_provider_integration(
|
||||
"message": "AI Provider 可访问,但当前 provider/model/key 未完整配置。",
|
||||
"status": status_result.model_dump(),
|
||||
}
|
||||
analysis_result = await client.analyze(
|
||||
prompt = await get_effective_prompt(db, AI_CONNECTION_TEST_PROMPT_KEY)
|
||||
probe_result = await client.analyze(
|
||||
SituationalAnalysisRequest(
|
||||
title="连接测试",
|
||||
objective="请用一句话回复连接可用。",
|
||||
observations=["这是配置中心发起的 LLM 连接测试。"],
|
||||
constraints=["回复尽量简短。"],
|
||||
title="快速连接测试",
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
observations=[],
|
||||
constraints=["Output only OK."],
|
||||
)
|
||||
)
|
||||
current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {})
|
||||
current_ocr = _normalize_ocr_payload(current_payload.get("ocr") or {})
|
||||
await save_setting_payload(
|
||||
db,
|
||||
"external_integrations",
|
||||
{"ai_provider": draft_ai_payload, "web_search": current_web_search, "ocr": current_ocr},
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"connected": True,
|
||||
"message": "AI Provider 连接成功,已保存为全局默认配置。",
|
||||
"message": "连接测试通过",
|
||||
"status": status_result.model_dump(),
|
||||
"provider": analysis_result.provider,
|
||||
"model": analysis_result.model,
|
||||
"integrations": await serialize_external_integrations(db),
|
||||
"provider": probe_result.provider,
|
||||
"model": probe_result.model,
|
||||
"mode": "quick_probe",
|
||||
}
|
||||
except HTTPException as exc:
|
||||
return {
|
||||
|
||||
@@ -1982,6 +1982,7 @@ async def collect_compute_center_location(
|
||||
provider_client=provider_client,
|
||||
query=query,
|
||||
entity_type="compute_center",
|
||||
db=db,
|
||||
attempted_queries=attempted_queries,
|
||||
search_evidence=search_result.evidence,
|
||||
)
|
||||
|
||||
@@ -58,7 +58,7 @@ async def websocket_endpoint(
|
||||
|
||||
is_anonymous = payload is None
|
||||
user_id = str(payload.get("sub")) if payload else f"anonymous:{id(websocket)}"
|
||||
supported_channels = ["vessels"] if is_anonymous else [
|
||||
supported_channels = ["vessels", "earth_news"] if is_anonymous else [
|
||||
"gpu_clusters",
|
||||
"submarine_cables",
|
||||
"ixp_nodes",
|
||||
@@ -66,6 +66,7 @@ async def websocket_endpoint(
|
||||
"dashboard",
|
||||
"datasource_tasks",
|
||||
"vessels",
|
||||
"earth_news",
|
||||
]
|
||||
await manager.connect(websocket, user_id)
|
||||
|
||||
|
||||
@@ -257,6 +257,16 @@ DEFAULT_DATASOURCES = {
|
||||
"credential_provider": "aisstream",
|
||||
"credential_status": "supported",
|
||||
},
|
||||
"media_news_archive": {
|
||||
"id": 33,
|
||||
"name": "Media News Archive",
|
||||
"display_name": "媒体新闻归档",
|
||||
"module": "L4",
|
||||
"priority": "P2",
|
||||
"frequency_minutes": 720,
|
||||
"is_free": True,
|
||||
"requires_credentials": False,
|
||||
},
|
||||
}
|
||||
|
||||
ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import bindparam, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
@@ -72,6 +72,74 @@ async def seed_default_datasources(session: AsyncSession):
|
||||
await session.commit()
|
||||
|
||||
|
||||
LEGACY_EARTH_BOUNDARY_SOURCES = (
|
||||
"earth_admin0_boundaries",
|
||||
"earth_coastline",
|
||||
"earth_claim_lines",
|
||||
"earth_boundary_tiles",
|
||||
)
|
||||
LEGACY_EARTH_BOUNDARY_DATATYPES = (
|
||||
"earth_boundary_source",
|
||||
"earth_boundary_tiles",
|
||||
)
|
||||
LEGACY_EARTH_BOUNDARY_IDS = (29, 30, 31, 32)
|
||||
|
||||
|
||||
async def purge_legacy_earth_boundary_datasources(session: AsyncSession) -> None:
|
||||
source_names = tuple(LEGACY_EARTH_BOUNDARY_SOURCES)
|
||||
source_ids = tuple(LEGACY_EARTH_BOUNDARY_IDS)
|
||||
data_types = tuple(LEGACY_EARTH_BOUNDARY_DATATYPES)
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM datasource_mapping_templates
|
||||
WHERE target_schema IN :data_types
|
||||
OR datasource_config_id IN (
|
||||
SELECT id FROM datasource_configs WHERE name IN :source_names
|
||||
)
|
||||
"""
|
||||
).bindparams(bindparam("source_names", expanding=True), bindparam("data_types", expanding=True)),
|
||||
{"source_names": list(source_names), "data_types": list(data_types)},
|
||||
)
|
||||
await session.execute(
|
||||
text("DELETE FROM datasource_configs WHERE name IN :source_names").bindparams(
|
||||
bindparam("source_names", expanding=True)
|
||||
),
|
||||
{"source_names": list(source_names)},
|
||||
)
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM collected_data
|
||||
WHERE source IN :source_names OR data_type IN :data_types
|
||||
"""
|
||||
).bindparams(bindparam("source_names", expanding=True), bindparam("data_types", expanding=True)),
|
||||
{"source_names": list(source_names), "data_types": list(data_types)},
|
||||
)
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM data_snapshots
|
||||
WHERE source IN :source_names OR datasource_id IN :source_ids
|
||||
"""
|
||||
).bindparams(bindparam("source_names", expanding=True), bindparam("source_ids", expanding=True)),
|
||||
{"source_names": list(source_names), "source_ids": list(source_ids)},
|
||||
)
|
||||
await session.execute(
|
||||
text("DELETE FROM collection_tasks WHERE datasource_id IN :source_ids").bindparams(
|
||||
bindparam("source_ids", expanding=True)
|
||||
),
|
||||
{"source_ids": list(source_ids)},
|
||||
)
|
||||
await session.execute(
|
||||
text("DELETE FROM data_sources WHERE source IN :source_names OR id IN :source_ids").bindparams(
|
||||
bindparam("source_names", expanding=True), bindparam("source_ids", expanding=True)
|
||||
),
|
||||
{"source_names": list(source_names), "source_ids": list(source_ids)},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
DEFAULT_LOGIN_USERS = (
|
||||
{
|
||||
"username": "admin",
|
||||
@@ -134,6 +202,7 @@ async def init_db():
|
||||
import app.models.vessel # noqa: F401
|
||||
import app.models.vessel_enrichment # noqa: F401
|
||||
import app.models.datasource_mapping # noqa: F401
|
||||
import app.models.earth_news # noqa: F401
|
||||
|
||||
logger.warning_event(
|
||||
"Database pool settings active",
|
||||
@@ -202,6 +271,18 @@ async def init_db():
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
ALTER TABLE earth_news_items
|
||||
ADD COLUMN IF NOT EXISTS content_language VARCHAR(32) NOT NULL DEFAULT 'en',
|
||||
ADD COLUMN IF NOT EXISTS localizations JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS enrichment_status VARCHAR(80) NOT NULL DEFAULT 'pending',
|
||||
ADD COLUMN IF NOT EXISTS enrichment_error TEXT,
|
||||
ADD COLUMN IF NOT EXISTS enriched_at TIMESTAMPTZ
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -210,6 +291,22 @@ async def init_db():
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_earth_news_enrichment_status
|
||||
ON earth_news_items (enrichment_status)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_earth_news_enriched_at
|
||||
ON earth_news_items (enriched_at)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -284,4 +381,5 @@ async def init_db():
|
||||
await seed_default_bgp_collector_locations(session)
|
||||
await seed_compute_center_locations_from_source_coords(session)
|
||||
await seed_default_datasources(session)
|
||||
await purge_legacy_earth_boundary_datasources(session)
|
||||
await ensure_default_admin_user(session)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.api.main import api_router
|
||||
@@ -18,6 +20,10 @@ from app.services.scheduler import (
|
||||
stop_scheduler,
|
||||
sync_scheduler_with_datasources,
|
||||
)
|
||||
from app.services.earth_news_worker import (
|
||||
start_earth_news_target_worker,
|
||||
stop_earth_news_target_worker,
|
||||
)
|
||||
|
||||
|
||||
configure_logging()
|
||||
@@ -53,7 +59,9 @@ async def lifespan(app: FastAPI):
|
||||
start_scheduler()
|
||||
await sync_scheduler_with_datasources()
|
||||
broadcaster.start()
|
||||
start_earth_news_target_worker()
|
||||
yield
|
||||
await stop_earth_news_target_worker()
|
||||
broadcaster.stop()
|
||||
stop_scheduler()
|
||||
|
||||
@@ -82,6 +90,14 @@ app.add_middleware(WebSocketCORSMiddleware)
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
app.include_router(websocket.router)
|
||||
|
||||
EARTH_BRAND_ASSET_DIR = Path(__file__).resolve().parents[2] / "data" / "earth-brand"
|
||||
EARTH_BRAND_ASSET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
app.mount(
|
||||
"/earth-brand-assets",
|
||||
StaticFiles(directory=str(EARTH_BRAND_ASSET_DIR)),
|
||||
name="earth-brand-assets",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.models.playground_message import PlaygroundMessage
|
||||
from app.models.system_log import SystemLog, AuditLog
|
||||
from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth, VesselPosition, VesselStatic
|
||||
from app.models.datasource_mapping import DataSourceMappingTemplate
|
||||
from app.models.earth_news import EarthNewsItem
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -43,4 +44,5 @@ __all__ = [
|
||||
"AISConflictRecord",
|
||||
"AISSourceHealth",
|
||||
"DataSourceMappingTemplate",
|
||||
"EarthNewsItem",
|
||||
]
|
||||
|
||||
40
backend/app/models/earth_news.py
Normal file
40
backend/app/models/earth_news.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, Index, JSON, String, Text
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class EarthNewsItem(Base):
|
||||
__tablename__ = "earth_news_items"
|
||||
|
||||
id = Column(String(160), primary_key=True)
|
||||
title = Column(String(500), nullable=False)
|
||||
summary = Column(Text, nullable=False, default="")
|
||||
content_language = Column(String(32), nullable=False, default="en")
|
||||
localizations = Column(JSON, nullable=False, default=dict)
|
||||
url = Column(Text, nullable=False)
|
||||
source = Column(String(255), nullable=False, default="")
|
||||
feed_name = Column(String(255), nullable=False, default="")
|
||||
region = Column(String(80), nullable=False, index=True)
|
||||
homepage_url = Column(Text, nullable=False, default="")
|
||||
published_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
latitude = Column(Float, nullable=False)
|
||||
longitude = Column(Float, nullable=False)
|
||||
location_label = Column(String(255), nullable=False)
|
||||
location_source = Column(String(80), nullable=False, default="region_anchor")
|
||||
verified = Column(Boolean, nullable=False, default=False, index=True)
|
||||
location_meta = Column(JSON, nullable=False, default=dict)
|
||||
|
||||
first_seen_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
last_seen_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
|
||||
resolved_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
enrichment_status = Column(String(80), nullable=False, default="pending", index=True)
|
||||
enrichment_error = Column(Text, nullable=True)
|
||||
enriched_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_earth_news_region_published", "region", "published_at"),
|
||||
Index("idx_earth_news_region_seen", "region", "last_seen_at"),
|
||||
)
|
||||
@@ -13,10 +13,11 @@ class AIContentBlock(BaseModel):
|
||||
|
||||
class SituationalAnalysisRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
objective: str = Field(..., min_length=1, max_length=1000)
|
||||
objective: str = Field(..., min_length=1, max_length=20000)
|
||||
context: dict[str, Any] = Field(default_factory=dict)
|
||||
observations: list[str] = Field(default_factory=list)
|
||||
constraints: list[str] = Field(default_factory=list)
|
||||
system_prompt: str | None = Field(default=None, max_length=8000)
|
||||
preferred_model: str | None = Field(default=None, max_length=200)
|
||||
thinking: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.alert import Alert, AlertSeverity, AlertStatus
|
||||
from app.schemas.ai import AlertBriefRequest, SituationalAnalysisRequest
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
|
||||
ALERT_BRIEF_PROMPT_KEY = "alerts.brief"
|
||||
|
||||
|
||||
def _format_counter(counter: Counter[str], empty_text: str = "无") -> str:
|
||||
@@ -84,11 +87,13 @@ async def build_alert_brief_request(
|
||||
"top_datasources": dict(datasource_counts.most_common(6)),
|
||||
"top_active_datasources": dict(active_datasource_counts.most_common(5)),
|
||||
}
|
||||
prompt = await get_effective_prompt(db, ALERT_BRIEF_PROMPT_KEY)
|
||||
|
||||
return (
|
||||
SituationalAnalysisRequest(
|
||||
title="告警态势 AI 简报",
|
||||
objective="基于当前告警总量、严重度、状态、数据源分布与最近告警摘录,生成一份面向值班人员的简明告警态势简报,突出待处理风险、告警集中点和优先动作。",
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
observations=facts,
|
||||
constraints=[
|
||||
"明确区分事实、推断与建议。",
|
||||
|
||||
@@ -11,9 +11,12 @@ from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||
from app.services.bgp_enrichment import lookup_prefix_geography
|
||||
|
||||
BGP_BRIEF_PROMPT_KEY = "bgp.brief"
|
||||
|
||||
|
||||
def _format_counter(counter: dict[str, int], empty_text: str = "无") -> str:
|
||||
if not counter:
|
||||
@@ -243,12 +246,15 @@ async def build_bgp_brief_request(
|
||||
for prefix, item in list(prefix_geographies.items())[:8]
|
||||
},
|
||||
}
|
||||
prompt = await get_effective_prompt(db, BGP_BRIEF_PROMPT_KEY)
|
||||
|
||||
return SituationalAnalysisRequest(
|
||||
title="BGP 态势 AI 简报",
|
||||
objective="基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。",
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
observations=observations_lines,
|
||||
constraints=[
|
||||
"直接输出中文 Markdown 简报正文,不要输出英文写作计划、提示词复述、字段说明或元评论。",
|
||||
"明确区分事实、推断与建议。",
|
||||
"优先指出需要立即关注的高严重度 incident 或异常模式。",
|
||||
"需要单独指出哪些区域结论来自 prefix geography / affected regions,哪些可能受 collector coverage 偏差影响。",
|
||||
|
||||
@@ -36,6 +36,7 @@ from app.services.collectors.iptoasn import IPtoASNPrefixGeoCollector
|
||||
from app.services.collectors.opengeofeed import OpenGeoFeedPrefixGeoCollector
|
||||
from app.services.collectors.nro_delegated import NRODelegatedPrefixGeoCollector
|
||||
from app.services.collectors.news_live_streams import NewsLiveStreamsCollector
|
||||
from app.services.collectors.media_news_archive import MediaNewsArchiveCollector
|
||||
from app.services.collectors.aisstream import AISStreamCollector
|
||||
from app.services.collectors.vessel_ais import VesselAISCollector
|
||||
|
||||
@@ -65,6 +66,7 @@ collector_registry.register(IPtoASNPrefixGeoCollector())
|
||||
collector_registry.register(OpenGeoFeedPrefixGeoCollector())
|
||||
collector_registry.register(NRODelegatedPrefixGeoCollector())
|
||||
collector_registry.register(NewsLiveStreamsCollector())
|
||||
collector_registry.register(MediaNewsArchiveCollector())
|
||||
collector_registry.register(VesselAISCollector())
|
||||
collector_registry.register(AISStreamCollector())
|
||||
|
||||
@@ -100,6 +102,7 @@ __all__ = [
|
||||
"OpenGeoFeedPrefixGeoCollector",
|
||||
"NRODelegatedPrefixGeoCollector",
|
||||
"NewsLiveStreamsCollector",
|
||||
"MediaNewsArchiveCollector",
|
||||
"VesselAISCollector",
|
||||
"AISStreamCollector",
|
||||
]
|
||||
|
||||
57
backend/app/services/collectors/media_news_archive.py
Normal file
57
backend/app/services/collectors/media_news_archive.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.services.collectors.base import BaseCollector
|
||||
from app.services.earth_news_store import list_all_earth_news_records
|
||||
|
||||
|
||||
class MediaNewsArchiveCollector(BaseCollector):
|
||||
name = "media_news_archive"
|
||||
priority = "P2"
|
||||
module = "L4"
|
||||
frequency_hours = 12
|
||||
data_type = "news_item"
|
||||
fail_on_empty = False
|
||||
|
||||
async def fetch(self) -> list[dict[str, Any]]:
|
||||
if not self._db_session:
|
||||
return []
|
||||
|
||||
records = await list_all_earth_news_records(self._db_session)
|
||||
items: list[dict[str, Any]] = []
|
||||
for record in records:
|
||||
location_meta = dict(record.location_meta or {})
|
||||
target = location_meta.get("target") if isinstance(location_meta.get("target"), dict) else {}
|
||||
country = target.get("country")
|
||||
city = target.get("city")
|
||||
items.append(
|
||||
{
|
||||
"id": record.id,
|
||||
"source_id": record.id,
|
||||
"name": record.title,
|
||||
"title": record.title,
|
||||
"description": record.summary,
|
||||
"country": country,
|
||||
"city": city,
|
||||
"latitude": record.latitude,
|
||||
"longitude": record.longitude,
|
||||
"reference_date": record.published_at,
|
||||
"metadata": {
|
||||
"url": record.url,
|
||||
"source": record.source,
|
||||
"feed_name": record.feed_name,
|
||||
"region": record.region,
|
||||
"homepage_url": record.homepage_url,
|
||||
"published_at": record.published_at.isoformat() if record.published_at else None,
|
||||
"location_label": record.location_label,
|
||||
"location_source": record.location_source,
|
||||
"verified": record.verified,
|
||||
"location_meta": location_meta,
|
||||
"first_seen_at": record.first_seen_at.isoformat() if record.first_seen_at else None,
|
||||
"last_seen_at": record.last_seen_at.isoformat() if record.last_seen_at else None,
|
||||
"resolved_at": record.resolved_at.isoformat() if record.resolved_at else None,
|
||||
},
|
||||
}
|
||||
)
|
||||
return items
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.services.ai_client import AIProviderClient
|
||||
@@ -15,6 +16,7 @@ from app.services.ai_tools.web_search import WebSearchClient, WebSearchError
|
||||
|
||||
|
||||
CREDENTIAL_GUIDES_CATEGORY = "collector_credential_guides"
|
||||
CREDENTIAL_GUIDE_PROMPT_KEY = "credential.guide"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -240,14 +242,12 @@ async def generate_credential_guide(
|
||||
guide["sources"] = []
|
||||
return guide
|
||||
|
||||
prompt = await get_effective_prompt(db, CREDENTIAL_GUIDE_PROMPT_KEY)
|
||||
response = await ai_client.analyze(
|
||||
SituationalAnalysisRequest(
|
||||
title=f"Generate credential guide for {provider}",
|
||||
objective=(
|
||||
default.prompt
|
||||
+ "\n只能根据 context.search_evidence 中的来源生成教程;"
|
||||
+ "如果证据不足,明确说明需要以官方页面为准。"
|
||||
),
|
||||
objective=f"{default.prompt}\n{prompt.prompt}",
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
context={
|
||||
"provider": provider,
|
||||
"current_default_guide": default.markdown,
|
||||
|
||||
@@ -8,6 +8,7 @@ import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.target_schema_registry import TargetSchema, get_target_schema
|
||||
@@ -254,6 +255,12 @@ def _best_field_match(field_name: str, candidates: list[str]) -> str | None:
|
||||
"lat": ("lat", "latitude", "y"),
|
||||
"lon": ("lon", "lng", "longitude", "x"),
|
||||
"mmsi": ("mmsi",),
|
||||
"geometry": ("geometry", "geom"),
|
||||
"properties": ("properties", "props"),
|
||||
"source_kind": ("source_kind", "kind", "type"),
|
||||
"feature_count": ("feature_count", "features_count", "count"),
|
||||
"artifact_path": ("artifact_path", "path", "file"),
|
||||
"sha256": ("sha256", "hash", "checksum"),
|
||||
"sog": ("sog", "speed", "speedOverGround"),
|
||||
"cog": ("cog", "course", "courseOverGround"),
|
||||
"received_at": ("received_at", "timestamp", "time", "updated_at"),
|
||||
|
||||
671
backend/app/services/earth_boundaries.py
Normal file
671
backend/app/services/earth_boundaries.py
Normal file
@@ -0,0 +1,671 @@
|
||||
"""Earth boundary static asset service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
SOURCE_OUTPUT_DIR = REPO_ROOT / "data/earth-boundary-sources"
|
||||
SOURCE_MANIFEST_PATH = SOURCE_OUTPUT_DIR / "manifest.json"
|
||||
BUILD_RESULT_PATH = SOURCE_OUTPUT_DIR / "build-result.json"
|
||||
BUILD_JOB_PATH = SOURCE_OUTPUT_DIR / "build-job.json"
|
||||
BOUNDARY_OUTPUT_DIR = REPO_ROOT / "frontend/public/earth/data/boundaries/v1"
|
||||
BOUNDARY_MANIFEST_PATH = BOUNDARY_OUTPUT_DIR / "manifest.json"
|
||||
PMTILES_ARTIFACT_PATH = (
|
||||
REPO_ROOT / "frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"
|
||||
)
|
||||
LEGACY_GEOJSON_PATH = REPO_ROOT / "frontend/public/earth/data/countries-admin0.min.geojson"
|
||||
POV_POLICY_PATH = REPO_ROOT / "config/earth-boundary-pov-policy.china-v1.json"
|
||||
LOCAL_CONFIG_PATH = REPO_ROOT / "config/earth-boundary-sources.local.json"
|
||||
EXAMPLE_CONFIG_PATH = REPO_ROOT / "config/earth-boundary-sources.example.json"
|
||||
|
||||
BOUNDARY_SOURCE_KINDS = {
|
||||
"earth_admin0_boundaries": "admin0-boundaries",
|
||||
"earth_coastline": "coastline",
|
||||
"earth_claim_lines": "claim-lines",
|
||||
}
|
||||
|
||||
DEFAULT_PUBLIC_BOUNDARY_SOURCES = {
|
||||
"earth_admin0_boundaries": {
|
||||
"displayName": "Natural Earth Admin-0 Countries",
|
||||
"sourceKind": "admin0-boundaries",
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_countries.geojson",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"auth_type": "none",
|
||||
"license": "Natural Earth public domain",
|
||||
},
|
||||
"earth_coastline": {
|
||||
"displayName": "Natural Earth Coastline",
|
||||
"sourceKind": "coastline",
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_coastline.geojson",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"auth_type": "none",
|
||||
"license": "Natural Earth public domain",
|
||||
},
|
||||
"earth_claim_lines": {
|
||||
"displayName": "Natural Earth Disputed Boundaries",
|
||||
"sourceKind": "claim-lines",
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_boundary_lines_disputed_areas.geojson",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"auth_type": "none",
|
||||
"license": "Natural Earth public domain",
|
||||
},
|
||||
}
|
||||
|
||||
BUILD_CONFIG = {
|
||||
"builder": "scripts/build_earth_boundary_pmtiles.py",
|
||||
"format": "pmtiles+mvt",
|
||||
"production_target": "pmtiles-mvt",
|
||||
}
|
||||
|
||||
|
||||
class EarthBoundaryBuildError(RuntimeError):
|
||||
def __init__(self, message: str, *, code: str = "build_failed", details: Any = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.details = details
|
||||
|
||||
|
||||
_build_job_lock = asyncio.Lock()
|
||||
_build_task: asyncio.Task | None = None
|
||||
_build_job_state: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _public_job_state() -> dict[str, Any]:
|
||||
if _build_job_state:
|
||||
return dict(_build_job_state)
|
||||
return _read_json(BUILD_JOB_PATH)
|
||||
|
||||
|
||||
def get_boundary_build_status() -> dict[str, Any]:
|
||||
return {"job": _public_job_state()}
|
||||
|
||||
|
||||
def _set_job_state(**updates: Any) -> dict[str, Any]:
|
||||
global _build_job_state
|
||||
current = dict(_build_job_state)
|
||||
current.update(updates)
|
||||
current["updated_at"] = _utc_now_iso()
|
||||
_build_job_state = current
|
||||
_write_json(BUILD_JOB_PATH, current)
|
||||
return current
|
||||
|
||||
|
||||
def _append_job_log(message: str) -> None:
|
||||
logs = list(_build_job_state.get("logs") or [])
|
||||
logs.append({"time": _utc_now_iso(), "message": message})
|
||||
_set_job_state(logs=logs[-40:])
|
||||
|
||||
|
||||
def _update_job_progress(progress: float, phase: str, message: str, **extra: Any) -> None:
|
||||
bounded_progress = max(0, min(100, int(round(progress))))
|
||||
_set_job_state(
|
||||
status="running",
|
||||
progress=bounded_progress,
|
||||
phase=phase,
|
||||
message=message,
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def _sha256_bytes(payload: bytes) -> str:
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _stable_json_hash(payload: Any) -> str:
|
||||
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _artifact_extension(endpoint: str, content_type: str, payload: bytes) -> str:
|
||||
suffix = Path(endpoint.split("?", 1)[0]).suffix.lower()
|
||||
if suffix in {".json", ".geojson", ".zip", ".pbf"}:
|
||||
return suffix
|
||||
if "geo+json" in content_type or b'"FeatureCollection"' in payload[:4096]:
|
||||
return ".geojson"
|
||||
if "json" in content_type:
|
||||
return ".json"
|
||||
return ".dat"
|
||||
|
||||
|
||||
def _json_feature_count(payload: Any) -> int:
|
||||
if isinstance(payload, dict) and isinstance(payload.get("features"), list):
|
||||
return len(payload["features"])
|
||||
if isinstance(payload, list):
|
||||
return len(payload)
|
||||
return 1 if payload else 0
|
||||
|
||||
|
||||
def _directory_stats(path: Path) -> dict[str, int]:
|
||||
if not path.exists():
|
||||
return {"file_count": 0, "size_bytes": 0}
|
||||
files = [item for item in path.rglob("*") if item.is_file()]
|
||||
return {"file_count": len(files), "size_bytes": sum(item.stat().st_size for item in files)}
|
||||
|
||||
|
||||
def _load_source_feature_collection(source: dict[str, Any]) -> dict[str, Any]:
|
||||
path = REPO_ROOT / source["path"]
|
||||
payload = _read_json(path)
|
||||
features = payload.get("features") if isinstance(payload, dict) else None
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": features if isinstance(features, list) else [],
|
||||
}
|
||||
|
||||
|
||||
def _write_high_precision_geojson_manifest(
|
||||
sources: list[dict[str, Any]],
|
||||
build_input_hash: str,
|
||||
missing_tools: list[str],
|
||||
) -> dict[str, Any]:
|
||||
BOUNDARY_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries")
|
||||
coastline = next(source for source in sources if source["kind"] == "coastline")
|
||||
claim_lines = next(source for source in sources if source["kind"] == "claim-lines")
|
||||
|
||||
admin0_payload = _load_source_feature_collection(admin0)
|
||||
coastline_payload = _load_source_feature_collection(coastline)
|
||||
claim_payload = _load_source_feature_collection(claim_lines)
|
||||
for feature in coastline_payload["features"]:
|
||||
props = feature.setdefault("properties", {})
|
||||
if isinstance(props, dict):
|
||||
props["PLANET_LAYER"] = "coastline"
|
||||
|
||||
base_payload = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [*admin0_payload["features"], *coastline_payload["features"]],
|
||||
}
|
||||
base_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-base.geojson"
|
||||
hover_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-hover.geojson"
|
||||
claim_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-claims.geojson"
|
||||
_write_json(base_path, base_payload)
|
||||
_write_json(hover_path, admin0_payload)
|
||||
_write_json(claim_path, claim_payload)
|
||||
|
||||
manifest = {
|
||||
"version": "natural-earth-v1",
|
||||
"builtAt": _utc_now_iso(),
|
||||
"tileProvider": "geojson-high-precision",
|
||||
"format": "geojson-directory",
|
||||
"buildInputHash": build_input_hash,
|
||||
"base": base_path.name,
|
||||
"hoverIndex": hover_path.name,
|
||||
"claimLine": claim_path.name,
|
||||
"sourceFeatureCount": {
|
||||
"admin0": len(admin0_payload["features"]),
|
||||
"coastline": len(coastline_payload["features"]),
|
||||
"claimLines": len(claim_payload["features"]),
|
||||
},
|
||||
"pmtiles": None,
|
||||
"missingTools": missing_tools,
|
||||
}
|
||||
_write_json(BOUNDARY_MANIFEST_PATH, manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def _relative(path: Path) -> str:
|
||||
return str(path.relative_to(REPO_ROOT))
|
||||
|
||||
|
||||
def load_boundary_config() -> tuple[dict[str, Any], str]:
|
||||
if LOCAL_CONFIG_PATH.exists():
|
||||
return _read_json(LOCAL_CONFIG_PATH), "local"
|
||||
return _read_json(EXAMPLE_CONFIG_PATH), "example"
|
||||
|
||||
|
||||
def save_boundary_config(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise EarthBoundaryBuildError("Earth boundary config must be a JSON object", code="invalid_config")
|
||||
_write_json(LOCAL_CONFIG_PATH, payload)
|
||||
return get_boundary_status()
|
||||
|
||||
|
||||
def _source_configs(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raw_sources = payload.get("collectorConfigs") or payload.get("sources") or {}
|
||||
return raw_sources if isinstance(raw_sources, dict) else {}
|
||||
|
||||
|
||||
def _is_placeholder_endpoint(endpoint: Any) -> bool:
|
||||
value = str(endpoint or "").strip()
|
||||
return not value or "example.com" in value
|
||||
|
||||
|
||||
def _source_configs_with_defaults(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raw_sources = _source_configs(payload)
|
||||
merged: dict[str, Any] = {}
|
||||
for source_key, default_config in DEFAULT_PUBLIC_BOUNDARY_SOURCES.items():
|
||||
configured = raw_sources.get(source_key)
|
||||
if not isinstance(configured, dict) or _is_placeholder_endpoint(configured.get("endpoint")):
|
||||
merged[source_key] = dict(default_config)
|
||||
else:
|
||||
merged[source_key] = {**default_config, **configured}
|
||||
for source_key, source_config in raw_sources.items():
|
||||
if source_key not in merged:
|
||||
merged[source_key] = source_config
|
||||
return merged
|
||||
|
||||
|
||||
def _build_input_hash(source_manifest: dict[str, Any]) -> str:
|
||||
return _stable_json_hash(
|
||||
{
|
||||
"source_manifest_schema": source_manifest.get("schema"),
|
||||
"sources": [
|
||||
{
|
||||
"id": source.get("id"),
|
||||
"sha256": source.get("sha256"),
|
||||
"kind": source.get("kind"),
|
||||
}
|
||||
for source in source_manifest.get("sources", [])
|
||||
],
|
||||
"pov_policy": source_manifest.get("povPolicy"),
|
||||
"build_config": BUILD_CONFIG,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _has_current_artifacts(boundary_manifest: dict[str, Any], build_input_hash: str) -> bool:
|
||||
return (
|
||||
bool(boundary_manifest)
|
||||
and boundary_manifest.get("buildInputHash") == build_input_hash
|
||||
and boundary_manifest.get("tileProvider") == "pmtiles-mvt"
|
||||
and PMTILES_ARTIFACT_PATH.exists()
|
||||
)
|
||||
|
||||
|
||||
def get_boundary_status() -> dict[str, Any]:
|
||||
config_payload, config_source = load_boundary_config()
|
||||
effective_source_configs = _source_configs_with_defaults(config_payload)
|
||||
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
|
||||
pmtiles_exists = PMTILES_ARTIFACT_PATH.exists()
|
||||
manifest_exists = BOUNDARY_MANIFEST_PATH.exists()
|
||||
high_precision_ready = (
|
||||
manifest_exists
|
||||
and (
|
||||
(
|
||||
boundary_manifest.get("tileProvider") == "pmtiles-mvt"
|
||||
and pmtiles_exists
|
||||
)
|
||||
or boundary_manifest.get("tileProvider") == "geojson-high-precision"
|
||||
)
|
||||
)
|
||||
legacy_exists = LEGACY_GEOJSON_PATH.exists()
|
||||
provider = (
|
||||
boundary_manifest.get("tileProvider")
|
||||
if high_precision_ready
|
||||
else "legacy-geojson" if legacy_exists else "missing"
|
||||
)
|
||||
return {
|
||||
"provider": provider,
|
||||
"high_precision_ready": high_precision_ready,
|
||||
"fallback_available": legacy_exists,
|
||||
"config_source": config_source,
|
||||
"config_path": _relative(LOCAL_CONFIG_PATH),
|
||||
"config_exists": LOCAL_CONFIG_PATH.exists(),
|
||||
"config": config_payload,
|
||||
"effective_default_sources": [
|
||||
source_key
|
||||
for source_key, source_config in effective_source_configs.items()
|
||||
if source_key in DEFAULT_PUBLIC_BOUNDARY_SOURCES
|
||||
and source_config.get("endpoint") == DEFAULT_PUBLIC_BOUNDARY_SOURCES[source_key]["endpoint"]
|
||||
],
|
||||
"manifest": {
|
||||
"path": _relative(BOUNDARY_MANIFEST_PATH),
|
||||
"exists": manifest_exists,
|
||||
"tileProvider": boundary_manifest.get("tileProvider"),
|
||||
"buildInputHash": boundary_manifest.get("buildInputHash"),
|
||||
"builtAt": boundary_manifest.get("builtAt"),
|
||||
},
|
||||
"pmtiles": {
|
||||
"path": _relative(PMTILES_ARTIFACT_PATH),
|
||||
"exists": pmtiles_exists,
|
||||
"size_bytes": PMTILES_ARTIFACT_PATH.stat().st_size if pmtiles_exists else 0,
|
||||
},
|
||||
"legacy": {
|
||||
"path": _relative(LEGACY_GEOJSON_PATH),
|
||||
"exists": legacy_exists,
|
||||
"size_bytes": LEGACY_GEOJSON_PATH.stat().st_size if legacy_exists else 0,
|
||||
},
|
||||
"source_manifest": {
|
||||
"path": _relative(SOURCE_MANIFEST_PATH),
|
||||
"exists": SOURCE_MANIFEST_PATH.exists(),
|
||||
},
|
||||
"last_build": _read_json(BUILD_RESULT_PATH),
|
||||
"current_job": _public_job_state(),
|
||||
}
|
||||
|
||||
|
||||
async def _download_source(
|
||||
source_key: str,
|
||||
source_config: dict[str, Any],
|
||||
progress_callback: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
endpoint = str(source_config.get("endpoint") or "").strip()
|
||||
if _is_placeholder_endpoint(endpoint):
|
||||
raise EarthBoundaryBuildError(
|
||||
f"{source_key} endpoint is not configured",
|
||||
code="source_not_configured",
|
||||
details={"source": source_key},
|
||||
)
|
||||
method = str(source_config.get("method") or "GET").upper()
|
||||
if method not in {"GET", "POST"}:
|
||||
raise EarthBoundaryBuildError(
|
||||
f"{source_key} method must be GET or POST",
|
||||
code="invalid_config",
|
||||
details={"source": source_key, "method": method},
|
||||
)
|
||||
|
||||
if endpoint.startswith("file://") or Path(endpoint).expanduser().exists():
|
||||
payload = Path(endpoint.removeprefix("file://")).expanduser().read_bytes()
|
||||
content_type = "application/octet-stream"
|
||||
if progress_callback:
|
||||
progress_callback(1, len(payload), len(payload))
|
||||
else:
|
||||
timeout = float(source_config.get("timeout") or 120)
|
||||
headers = source_config.get("headers") if isinstance(source_config.get("headers"), dict) else {}
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
async with client.stream(method, endpoint, headers=headers) as response:
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get("content-type", "")
|
||||
total = int(response.headers.get("content-length") or 0)
|
||||
chunks = []
|
||||
downloaded = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
if not chunk:
|
||||
continue
|
||||
chunks.append(chunk)
|
||||
downloaded += len(chunk)
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
(downloaded / total) if total else None,
|
||||
downloaded,
|
||||
total,
|
||||
)
|
||||
payload = b"".join(chunks)
|
||||
|
||||
extension = _artifact_extension(endpoint, content_type, payload)
|
||||
parsed: Any = None
|
||||
if extension in {".json", ".geojson"}:
|
||||
parsed = json.loads(payload.decode("utf-8"))
|
||||
feature_count = _json_feature_count(parsed)
|
||||
if feature_count <= 0:
|
||||
raise EarthBoundaryBuildError(
|
||||
f"{source_key} downloaded payload contains no features",
|
||||
code="empty_source",
|
||||
details={"source": source_key},
|
||||
)
|
||||
|
||||
sha256 = _sha256_bytes(payload)
|
||||
source_dir = SOURCE_OUTPUT_DIR / source_key
|
||||
source_dir.mkdir(parents=True, exist_ok=True)
|
||||
artifact_path = source_dir / f"{sha256}{extension}"
|
||||
artifact_path.write_bytes(payload)
|
||||
return {
|
||||
"id": source_key,
|
||||
"kind": source_config.get("sourceKind") or BOUNDARY_SOURCE_KINDS[source_key],
|
||||
"path": _relative(artifact_path),
|
||||
"sha256": sha256,
|
||||
"featureCount": feature_count,
|
||||
"license": source_config.get("license"),
|
||||
}
|
||||
|
||||
|
||||
async def _run_step(args: list[str]) -> dict[str, Any]:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
*args,
|
||||
cwd=REPO_ROOT,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout_bytes, stderr_bytes = await process.communicate()
|
||||
stdout = stdout_bytes.decode("utf-8", errors="replace").strip()
|
||||
stderr = stderr_bytes.decode("utf-8", errors="replace").strip()
|
||||
payload: dict[str, Any] = {"stdout": stdout, "stderr": stderr, "returncode": process.returncode}
|
||||
last_line = stdout.splitlines()[-1:] or []
|
||||
if last_line:
|
||||
try:
|
||||
payload["result"] = json.loads(last_line[0])
|
||||
except json.JSONDecodeError:
|
||||
payload["result"] = last_line[0]
|
||||
if process.returncode != 0:
|
||||
raise EarthBoundaryBuildError(
|
||||
stderr or stdout or f"command failed: {' '.join(args)}",
|
||||
code="build_command_failed",
|
||||
details=payload,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def build_boundary_assets(progress_callback: Any = None) -> dict[str, Any]:
|
||||
config_payload, config_source = load_boundary_config()
|
||||
|
||||
source_configs = _source_configs_with_defaults(config_payload)
|
||||
missing = [source for source in BOUNDARY_SOURCE_KINDS if source not in source_configs]
|
||||
if missing:
|
||||
raise EarthBoundaryBuildError(
|
||||
f"Missing Earth boundary source configs: {', '.join(missing)}",
|
||||
code="missing_sources",
|
||||
details={"missing": missing},
|
||||
)
|
||||
|
||||
sources = []
|
||||
source_keys = list(BOUNDARY_SOURCE_KINDS)
|
||||
for index, source_key in enumerate(source_keys):
|
||||
source_config = source_configs[source_key]
|
||||
if not isinstance(source_config, dict):
|
||||
raise EarthBoundaryBuildError(
|
||||
f"{source_key} config must be an object",
|
||||
code="invalid_config",
|
||||
details={"source": source_key},
|
||||
)
|
||||
source_start = 8 + index * 18
|
||||
source_end = source_start + 18
|
||||
if progress_callback:
|
||||
progress_callback(source_start, "download", f"正在下载 {source_key}")
|
||||
|
||||
def report_download_progress(ratio: float | None, downloaded: int, total: int) -> None:
|
||||
if not progress_callback:
|
||||
return
|
||||
if ratio is None:
|
||||
progress_callback(source_start + 8, "download", f"{source_key} 已下载 {downloaded} bytes")
|
||||
return
|
||||
progress_callback(
|
||||
source_start + (source_end - source_start) * ratio,
|
||||
"download",
|
||||
f"{source_key} 下载 {int(ratio * 100)}%",
|
||||
downloaded_bytes=downloaded,
|
||||
total_bytes=total,
|
||||
)
|
||||
|
||||
sources.append(await _download_source(source_key, source_config, report_download_progress))
|
||||
|
||||
source_manifest = {
|
||||
"schema": "planet-earth-boundary-sources/v2",
|
||||
"sources": sources,
|
||||
"povPolicy": _read_json(POV_POLICY_PATH),
|
||||
}
|
||||
if progress_callback:
|
||||
progress_callback(65, "manifest", "正在写入边界源 manifest")
|
||||
_write_json(SOURCE_MANIFEST_PATH, source_manifest)
|
||||
build_input_hash = _build_input_hash(source_manifest)
|
||||
|
||||
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
|
||||
build_skipped = _has_current_artifacts(boundary_manifest, build_input_hash)
|
||||
missing_tools = [tool for tool in ("tippecanoe", "pmtiles") if shutil.which(tool) is None]
|
||||
if missing_tools and not build_skipped:
|
||||
if progress_callback:
|
||||
progress_callback(82, "build", "缺少 PMTiles 工具,正在生成 GeoJSON 高清包")
|
||||
boundary_manifest = _write_high_precision_geojson_manifest(
|
||||
sources,
|
||||
build_input_hash,
|
||||
missing_tools,
|
||||
)
|
||||
result = {
|
||||
"status": "built_geojson_fallback",
|
||||
"code": "missing_tools",
|
||||
"missing_tools": missing_tools,
|
||||
"sources": sources,
|
||||
"boundary_manifest": _relative(BOUNDARY_MANIFEST_PATH),
|
||||
"manifest": boundary_manifest,
|
||||
}
|
||||
_write_json(BUILD_RESULT_PATH, result)
|
||||
if progress_callback:
|
||||
progress_callback(96, "finalize", "GeoJSON 高清国界包已生成")
|
||||
return {**get_boundary_status(), "build": result}
|
||||
|
||||
if build_skipped:
|
||||
if progress_callback:
|
||||
progress_callback(96, "unchanged", "高精国界已是最新")
|
||||
build_result = {
|
||||
"status": "unchanged",
|
||||
"reason": "source manifest and build config hash unchanged",
|
||||
"buildInputHash": build_input_hash,
|
||||
}
|
||||
else:
|
||||
if progress_callback:
|
||||
progress_callback(72, "build", "正在构建 PMTiles/MVT")
|
||||
admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries")
|
||||
coastline = next(source for source in sources if source["kind"] == "coastline")
|
||||
claim_lines = next(source for source in sources if source["kind"] == "claim-lines")
|
||||
build_result = await _run_step(
|
||||
[
|
||||
"scripts/build_earth_boundary_pmtiles.py",
|
||||
"--admin0-source",
|
||||
admin0["path"],
|
||||
"--coastline-source",
|
||||
coastline["path"],
|
||||
"--claims-source",
|
||||
claim_lines["path"],
|
||||
"--output",
|
||||
_relative(PMTILES_ARTIFACT_PATH),
|
||||
"--manifest",
|
||||
_relative(BOUNDARY_MANIFEST_PATH),
|
||||
"--build-input-hash",
|
||||
build_input_hash,
|
||||
"--pov-policy",
|
||||
_relative(POV_POLICY_PATH),
|
||||
]
|
||||
)
|
||||
if progress_callback:
|
||||
progress_callback(95, "finalize", "正在校验构建产物")
|
||||
|
||||
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
|
||||
boundary_stats = _directory_stats(BOUNDARY_OUTPUT_DIR)
|
||||
result = {
|
||||
"status": "unchanged" if build_skipped else "built",
|
||||
"sources": sources,
|
||||
"source_manifest": _relative(SOURCE_MANIFEST_PATH),
|
||||
"boundary_manifest": _relative(BOUNDARY_MANIFEST_PATH),
|
||||
"pmtiles_artifact": _relative(PMTILES_ARTIFACT_PATH),
|
||||
"pmtiles_exists": PMTILES_ARTIFACT_PATH.exists(),
|
||||
"boundary_stats": boundary_stats,
|
||||
"manifest": boundary_manifest,
|
||||
"build_result": build_result,
|
||||
}
|
||||
_write_json(BUILD_RESULT_PATH, result)
|
||||
return {**get_boundary_status(), "build": result}
|
||||
|
||||
|
||||
async def _run_boundary_build_job(job_id: str) -> None:
|
||||
def report(progress: float, phase: str, message: str, **extra: Any) -> None:
|
||||
if _build_job_state.get("id") != job_id:
|
||||
return
|
||||
_update_job_progress(progress, phase, message, **extra)
|
||||
|
||||
try:
|
||||
report(3, "prepare", "正在准备高精国界构建")
|
||||
result = await build_boundary_assets(report)
|
||||
_set_job_state(
|
||||
id=job_id,
|
||||
status="succeeded",
|
||||
progress=100,
|
||||
phase="complete",
|
||||
message="高精国界构建完成",
|
||||
finished_at=_utc_now_iso(),
|
||||
result={
|
||||
"provider": result.get("provider"),
|
||||
"high_precision_ready": result.get("high_precision_ready"),
|
||||
"pmtiles": result.get("pmtiles"),
|
||||
"manifest": result.get("manifest"),
|
||||
},
|
||||
)
|
||||
_append_job_log("高精国界构建完成")
|
||||
except EarthBoundaryBuildError as exc:
|
||||
_set_job_state(
|
||||
id=job_id,
|
||||
status="failed",
|
||||
progress=_build_job_state.get("progress", 0),
|
||||
phase="failed",
|
||||
message=str(exc),
|
||||
code=exc.code,
|
||||
details=exc.details,
|
||||
finished_at=_utc_now_iso(),
|
||||
)
|
||||
_append_job_log(str(exc))
|
||||
except Exception as exc: # pragma: no cover - defensive guard for background task
|
||||
_set_job_state(
|
||||
id=job_id,
|
||||
status="failed",
|
||||
progress=_build_job_state.get("progress", 0),
|
||||
phase="failed",
|
||||
message=str(exc),
|
||||
code="build_failed",
|
||||
finished_at=_utc_now_iso(),
|
||||
)
|
||||
_append_job_log(str(exc))
|
||||
|
||||
|
||||
async def start_boundary_build_job() -> dict[str, Any]:
|
||||
global _build_task
|
||||
async with _build_job_lock:
|
||||
if _build_task and not _build_task.done():
|
||||
return {"accepted": False, "job": _public_job_state()}
|
||||
job_id = uuid4().hex
|
||||
_set_job_state(
|
||||
id=job_id,
|
||||
status="queued",
|
||||
progress=0,
|
||||
phase="queued",
|
||||
message="高精国界构建已加入队列",
|
||||
logs=[],
|
||||
started_at=_utc_now_iso(),
|
||||
finished_at=None,
|
||||
code=None,
|
||||
details=None,
|
||||
)
|
||||
_append_job_log("高精国界构建已启动")
|
||||
_build_task = asyncio.create_task(_run_boundary_build_job(job_id))
|
||||
return {"accepted": True, "job": _public_job_state()}
|
||||
@@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
@@ -13,6 +15,13 @@ import xml.etree.ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.countries import COUNTRY_VARIANTS_MAP, get_country_centroid, normalize_country
|
||||
from app.ai_tasks.prompts import EffectiveAIPrompt, get_effective_prompt
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.services.ai_client import AIProviderClient
|
||||
from app.services.location.resolvers.nominatim import build_default_nominatim_geocoder
|
||||
|
||||
|
||||
USER_AGENT = "PlanetEarthNewsBoard/1.0 (+https://planet.local)"
|
||||
@@ -20,6 +29,11 @@ REQUEST_TIMEOUT = 12.0
|
||||
MAX_ITEMS_PER_SOURCE = 6
|
||||
MAX_ITEMS_TOTAL = 12
|
||||
STALE_CACHE_MAX_AGE_SECONDS = 60 * 45
|
||||
RSS_SUPPLEMENT_MAX_AGE_SECONDS = STALE_CACHE_MAX_AGE_SECONDS
|
||||
MAX_TARGET_INFERENCE_CONCURRENCY = 3
|
||||
TARGET_INFERENCE_TIMEOUT_SECONDS = 6.0
|
||||
DEFAULT_NEWS_LOCALE = "zh-CN"
|
||||
NEWS_ENRICH_PROMPT_KEY = "earth.news.enrich"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -49,6 +63,17 @@ class NewsFeedSource:
|
||||
priority: int = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NewsTargetLocation:
|
||||
latitude: float
|
||||
longitude: float
|
||||
label: str
|
||||
source: str
|
||||
confidence: float | None = None
|
||||
country: str | None = None
|
||||
city: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedNewsItem:
|
||||
id: str
|
||||
@@ -60,6 +85,18 @@ class ParsedNewsItem:
|
||||
feed_region: str
|
||||
homepage_url: str
|
||||
published_at: datetime | None
|
||||
content_language: str = "en"
|
||||
localizations: dict[str, dict[str, str]] = field(default_factory=dict)
|
||||
enrichment_status: str = "pending"
|
||||
enrichment_error: str | None = None
|
||||
enriched_at: datetime | None = None
|
||||
target_location: NewsTargetLocation | None = None
|
||||
target_resolution_stage: str = "unresolved"
|
||||
target_ai_attempted: bool = False
|
||||
target_ai_status: str = "not_attempted"
|
||||
target_ai_error: str | None = None
|
||||
target_debug_note: str | None = None
|
||||
location_patch: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -236,6 +273,17 @@ NEWS_FEED_SOURCES: tuple[NewsFeedSource, ...] = (
|
||||
|
||||
|
||||
_REGION_CACHE: dict[str, CachedRegionFeed] = {}
|
||||
_news_target_geocode = build_default_nominatim_geocoder(user_agent=USER_AGENT)
|
||||
_CITY_HINTS: tuple[dict[str, str | None], ...] = (
|
||||
{"name": "Beijing", "country": "中国"},
|
||||
{"name": "Havana", "country": "古巴"},
|
||||
{"name": "Kyiv", "country": "乌克兰"},
|
||||
{"name": "Bangkok", "country": "泰国"},
|
||||
{"name": "Tehran", "country": "伊朗"},
|
||||
{"name": "Moscow", "country": "俄罗斯"},
|
||||
{"name": "Taipei", "country": "中国(台湾)"},
|
||||
{"name": "Hong Kong", "country": "中国(香港)"},
|
||||
)
|
||||
|
||||
|
||||
def determine_focus_region(lat: float | None, lon: float | None) -> str:
|
||||
@@ -258,6 +306,462 @@ def get_region_anchor(region: str) -> RegionAnchor:
|
||||
return REGION_ANCHORS.get(region, REGION_ANCHORS["global"])
|
||||
|
||||
|
||||
def _coerce_str(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
value = str(value)
|
||||
cleaned = re.sub(r"\s+", " ", value).strip()
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _contains_location_alias(text: str, alias: str) -> bool:
|
||||
normalized_alias = _coerce_str(alias)
|
||||
if not normalized_alias:
|
||||
return False
|
||||
if re.search(r"[A-Za-z]", normalized_alias):
|
||||
pattern = r"(?<![A-Za-z])" + re.escape(normalized_alias) + r"(?![A-Za-z])"
|
||||
return re.search(pattern, text, flags=re.IGNORECASE) is not None
|
||||
return normalized_alias in text
|
||||
|
||||
|
||||
def _iter_searchable_country_variants(
|
||||
canonical: str,
|
||||
variants: list[str],
|
||||
) -> tuple[str, ...]:
|
||||
searchable: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for variant in (canonical, *variants):
|
||||
normalized = _coerce_str(variant)
|
||||
if not normalized:
|
||||
continue
|
||||
if re.fullmatch(r"[A-Z]{2,3}", normalized):
|
||||
continue
|
||||
if len(normalized) <= 2:
|
||||
continue
|
||||
key = normalized.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
searchable.append(normalized)
|
||||
return tuple(searchable)
|
||||
|
||||
|
||||
def _coerce_float(value: Any) -> float | None:
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not math.isfinite(parsed):
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _first_json_object(text: str) -> dict[str, Any] | None:
|
||||
if not text:
|
||||
return None
|
||||
decoder = json.JSONDecoder()
|
||||
for index, char in enumerate(text):
|
||||
if char != "{":
|
||||
continue
|
||||
try:
|
||||
payload, _ = decoder.raw_decode(text[index:])
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_localizations(value: Any) -> dict[str, dict[str, str]]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
normalized: dict[str, dict[str, str]] = {}
|
||||
for locale, payload in value.items():
|
||||
locale_key = _coerce_str(locale)
|
||||
if not locale_key or not isinstance(payload, dict):
|
||||
continue
|
||||
title = _coerce_str(payload.get("title"))
|
||||
summary = _coerce_str(payload.get("summary"))
|
||||
entry: dict[str, str] = {}
|
||||
if title:
|
||||
entry["title"] = title
|
||||
if summary:
|
||||
entry["summary"] = summary
|
||||
if entry:
|
||||
normalized[locale_key] = entry
|
||||
return normalized
|
||||
|
||||
|
||||
def _get_locale_text(
|
||||
item: ParsedNewsItem,
|
||||
key: str,
|
||||
*,
|
||||
locale: str = DEFAULT_NEWS_LOCALE,
|
||||
) -> str:
|
||||
localized = item.localizations.get(locale)
|
||||
if isinstance(localized, dict):
|
||||
value = _coerce_str(localized.get(key))
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _has_default_localization(item: ParsedNewsItem) -> bool:
|
||||
localized = item.localizations.get(DEFAULT_NEWS_LOCALE)
|
||||
if not isinstance(localized, dict):
|
||||
return False
|
||||
return bool(_coerce_str(localized.get("title")) and _coerce_str(localized.get("summary")))
|
||||
|
||||
|
||||
def apply_enrichment_patch_to_item(
|
||||
item: ParsedNewsItem,
|
||||
patch: dict[str, Any],
|
||||
) -> ParsedNewsItem:
|
||||
item.location_patch = patch
|
||||
if "content_language" in patch:
|
||||
item.content_language = _coerce_str(patch.get("content_language")) or item.content_language
|
||||
if "localizations" in patch:
|
||||
item.localizations = _normalize_localizations(patch.get("localizations"))
|
||||
if "enrichment_status" in patch:
|
||||
item.enrichment_status = _coerce_str(patch.get("enrichment_status")) or item.enrichment_status
|
||||
if "enrichment_error" in patch:
|
||||
item.enrichment_error = _coerce_str(patch.get("enrichment_error"))
|
||||
if "enriched_at" in patch:
|
||||
item.enriched_at = _parse_datetime(_coerce_str(patch.get("enriched_at")))
|
||||
return item
|
||||
|
||||
|
||||
async def _geocode_target_location(query: str) -> dict[str, Any] | None:
|
||||
return await asyncio.to_thread(_news_target_geocode, query)
|
||||
|
||||
|
||||
async def _build_target_location_from_payload(
|
||||
payload: dict[str, Any],
|
||||
) -> NewsTargetLocation | None:
|
||||
country = normalize_country(payload.get("country"))
|
||||
city = _coerce_str(payload.get("city"))
|
||||
matched_location_name = _coerce_str(payload.get("matched_location_name"))
|
||||
confidence = _coerce_float(payload.get("confidence"))
|
||||
if confidence is not None:
|
||||
confidence = max(0.0, min(confidence, 1.0))
|
||||
|
||||
latitude = _coerce_float(payload.get("latitude"))
|
||||
longitude = _coerce_float(payload.get("longitude"))
|
||||
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
|
||||
label = matched_location_name or ", ".join(part for part in (city, country) if part) or "关联位置"
|
||||
return NewsTargetLocation(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
label=label,
|
||||
source="ai_inferred_target",
|
||||
confidence=confidence,
|
||||
country=country,
|
||||
city=city,
|
||||
)
|
||||
|
||||
geocode_queries: list[str] = []
|
||||
for value in (
|
||||
", ".join(part for part in (city, country) if part),
|
||||
matched_location_name,
|
||||
city,
|
||||
country,
|
||||
):
|
||||
normalized = _coerce_str(value)
|
||||
if normalized and normalized not in geocode_queries:
|
||||
geocode_queries.append(normalized)
|
||||
|
||||
for query in geocode_queries:
|
||||
try:
|
||||
result = await _geocode_target_location(query)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
latitude = _coerce_float(result.get("lat"))
|
||||
longitude = _coerce_float(result.get("lon"))
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
continue
|
||||
label = (
|
||||
_coerce_str(result.get("display_name"))
|
||||
or matched_location_name
|
||||
or ", ".join(part for part in (city, country) if part)
|
||||
or query
|
||||
)
|
||||
return NewsTargetLocation(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
label=label,
|
||||
source="ai_inferred_target",
|
||||
confidence=confidence,
|
||||
country=country,
|
||||
city=city,
|
||||
)
|
||||
|
||||
centroid = get_country_centroid(country)
|
||||
if centroid:
|
||||
label = matched_location_name or city or country or "关联位置"
|
||||
return NewsTargetLocation(
|
||||
latitude=centroid["latitude"],
|
||||
longitude=centroid["longitude"],
|
||||
label=label,
|
||||
source="ai_inferred_target",
|
||||
confidence=confidence,
|
||||
country=country,
|
||||
city=city,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _extract_target_location_from_text(item: ParsedNewsItem) -> NewsTargetLocation | None:
|
||||
combined_text = " ".join(part for part in (item.title, item.summary) if part).strip()
|
||||
if not combined_text:
|
||||
return None
|
||||
|
||||
for hint in _CITY_HINTS:
|
||||
city_name = _coerce_str(hint.get("name"))
|
||||
if not city_name or not _contains_location_alias(combined_text, city_name):
|
||||
continue
|
||||
country = normalize_country(hint.get("country"))
|
||||
geocode_query = ", ".join(part for part in (city_name, country) if part)
|
||||
try:
|
||||
result = await _geocode_target_location(geocode_query)
|
||||
except Exception:
|
||||
result = None
|
||||
if isinstance(result, dict):
|
||||
latitude = _coerce_float(result.get("lat"))
|
||||
longitude = _coerce_float(result.get("lon"))
|
||||
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
|
||||
return NewsTargetLocation(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
label=_coerce_str(result.get("display_name")) or geocode_query,
|
||||
source="headline_location_hint",
|
||||
confidence=0.78,
|
||||
country=country,
|
||||
city=city_name,
|
||||
)
|
||||
centroid = get_country_centroid(country)
|
||||
if centroid:
|
||||
return NewsTargetLocation(
|
||||
latitude=centroid["latitude"],
|
||||
longitude=centroid["longitude"],
|
||||
label=geocode_query,
|
||||
source="headline_location_hint",
|
||||
confidence=0.68,
|
||||
country=country,
|
||||
city=city_name,
|
||||
)
|
||||
|
||||
for canonical, variants in COUNTRY_VARIANTS_MAP.items():
|
||||
if not get_country_centroid(canonical):
|
||||
continue
|
||||
searchable_variants = _iter_searchable_country_variants(canonical, variants)
|
||||
if not any(_contains_location_alias(combined_text, variant) for variant in searchable_variants):
|
||||
continue
|
||||
centroid = get_country_centroid(canonical)
|
||||
if not centroid:
|
||||
continue
|
||||
return NewsTargetLocation(
|
||||
latitude=centroid["latitude"],
|
||||
longitude=centroid["longitude"],
|
||||
label=canonical,
|
||||
source="headline_country_hint",
|
||||
confidence=0.62,
|
||||
country=canonical,
|
||||
city=None,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _infer_news_target_location(
|
||||
item: ParsedNewsItem,
|
||||
*,
|
||||
provider_client: AIProviderClient | None,
|
||||
prompt: EffectiveAIPrompt | None = None,
|
||||
) -> NewsTargetLocation | None:
|
||||
target, _localizations = await _infer_news_enrichment(
|
||||
item,
|
||||
provider_client=provider_client,
|
||||
prompt=prompt,
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
async def _infer_news_enrichment(
|
||||
item: ParsedNewsItem,
|
||||
*,
|
||||
provider_client: AIProviderClient | None,
|
||||
prompt: EffectiveAIPrompt | None = None,
|
||||
) -> tuple[NewsTargetLocation | None, dict[str, dict[str, str]]]:
|
||||
text_hint = await _extract_target_location_from_text(item)
|
||||
content_error: str | None = None
|
||||
if text_hint is not None and text_hint.city:
|
||||
item.target_resolution_stage = text_hint.source
|
||||
item.target_ai_attempted = False
|
||||
item.target_ai_status = "skipped_text_hint"
|
||||
item.target_ai_error = None
|
||||
item.target_debug_note = f"text hint matched {text_hint.label}"
|
||||
localizations: dict[str, dict[str, str]] = {}
|
||||
|
||||
if provider_client is None:
|
||||
if text_hint is None or not text_hint.city:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_attempted = False
|
||||
item.target_ai_status = "unavailable"
|
||||
item.target_ai_error = "AI provider is not configured or unavailable for earth-feed."
|
||||
item.target_debug_note = (
|
||||
f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched"
|
||||
)
|
||||
item.enrichment_status = "unavailable"
|
||||
item.enrichment_error = "AI provider is not configured or unavailable for earth-feed."
|
||||
return text_hint, localizations
|
||||
|
||||
if text_hint is None or not text_hint.city:
|
||||
item.target_ai_attempted = True
|
||||
item.target_ai_status = "attempted"
|
||||
item.target_ai_error = None
|
||||
item.target_debug_note = (
|
||||
f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched"
|
||||
)
|
||||
item.enrichment_status = "attempted"
|
||||
item.enrichment_error = None
|
||||
|
||||
prompt = prompt or await get_effective_prompt(None, NEWS_ENRICH_PROMPT_KEY)
|
||||
request = SituationalAnalysisRequest(
|
||||
title="Enrich Earth news item with event location and zh-CN content",
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
context={
|
||||
"news_item": {
|
||||
"title": item.title,
|
||||
"summary": item.summary,
|
||||
"source": item.source,
|
||||
"feed_name": item.feed_name,
|
||||
"feed_region": item.feed_region,
|
||||
"url": item.url,
|
||||
"published_at": (
|
||||
item.published_at.isoformat().replace("+00:00", "Z")
|
||||
if item.published_at
|
||||
else None
|
||||
),
|
||||
},
|
||||
"required_json_schema": {
|
||||
"location": {
|
||||
"country": "string|null",
|
||||
"city": "string|null",
|
||||
"matched_location_name": "string|null",
|
||||
"latitude": "number|null",
|
||||
"longitude": "number|null",
|
||||
"confidence": "number from 0 to 1",
|
||||
"reasoning_summary": "short string",
|
||||
},
|
||||
"localizations": {
|
||||
"zh-CN": {
|
||||
"title": "faithful Simplified Chinese title",
|
||||
"summary": "one-sentence newswire-style Simplified Chinese lead summary",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
constraints=[
|
||||
"Return only strict JSON. Do not wrap it in markdown.",
|
||||
"For localizations, do not add facts that are absent from the RSS headline, description, source, or date.",
|
||||
"Write zh-CN summary as one concise newswire-style sentence, like a breaking-news lead.",
|
||||
"If the RSS description is thin, write a conservative one-sentence summary that says only what is supported.",
|
||||
"Keep zh-CN summary factual, non-promotional, and avoid colon-heavy keyword labels.",
|
||||
"Prefer the event location, not the newsroom or publisher headquarters.",
|
||||
"When a country visit or summit is the clear topic but the city is omitted, use the most likely host city only if it is broadly public knowledge.",
|
||||
"Use null for unknown fields instead of inventing details.",
|
||||
"Calibrate confidence conservatively: 0.75+ only when the city is strongly supported, 0.55-0.74 for country-level or likely city inference, below 0.55 when weak.",
|
||||
],
|
||||
)
|
||||
try:
|
||||
response = await provider_client.analyze(request)
|
||||
except Exception as exc:
|
||||
if text_hint is None or not text_hint.city:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_status = "provider_error"
|
||||
item.target_ai_error = str(exc)
|
||||
item.enrichment_status = "provider_error"
|
||||
item.enrichment_error = str(exc)
|
||||
return text_hint, localizations
|
||||
|
||||
payload = _first_json_object(response.content)
|
||||
if not isinstance(payload, dict):
|
||||
if text_hint is None or not text_hint.city:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_status = "parse_error"
|
||||
item.target_ai_error = "AI response did not contain a parseable JSON object."
|
||||
item.enrichment_status = "parse_error"
|
||||
item.enrichment_error = "AI response did not contain a parseable JSON object."
|
||||
return text_hint, localizations
|
||||
|
||||
localizations = _normalize_localizations(payload.get("localizations"))
|
||||
if not localizations:
|
||||
content_error = "AI returned no usable localizations."
|
||||
|
||||
location_payload = payload.get("location") if isinstance(payload.get("location"), dict) else payload
|
||||
if text_hint is not None and text_hint.city:
|
||||
target = text_hint
|
||||
else:
|
||||
target = await _build_target_location_from_payload(location_payload)
|
||||
if target is None:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_status = "no_result"
|
||||
item.target_ai_error = "AI returned no usable target coordinates or geocodeable location."
|
||||
target = text_hint
|
||||
elif target.confidence is not None and target.confidence < 0.45:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_status = "low_confidence"
|
||||
item.target_ai_error = f"AI target confidence too low: {target.confidence:.2f}"
|
||||
target = text_hint
|
||||
else:
|
||||
item.target_resolution_stage = target.source
|
||||
item.target_ai_status = "success"
|
||||
item.target_ai_error = None
|
||||
item.target_debug_note = f"ai inferred {target.label}"
|
||||
|
||||
item.localizations = localizations
|
||||
if localizations and item.target_ai_status in {"success", "skipped_text_hint"}:
|
||||
item.enrichment_status = "success"
|
||||
item.enrichment_error = None
|
||||
elif localizations:
|
||||
item.enrichment_status = "content_only"
|
||||
item.enrichment_error = item.target_ai_error
|
||||
else:
|
||||
item.enrichment_status = "location_only" if target is not None else "no_result"
|
||||
item.enrichment_error = content_error or item.target_ai_error
|
||||
item.enriched_at = datetime.now(UTC) if localizations else None
|
||||
return target, localizations
|
||||
|
||||
|
||||
async def _enrich_items_with_target_locations(
|
||||
items: list[ParsedNewsItem],
|
||||
*,
|
||||
provider_client: AIProviderClient | None,
|
||||
prompt: EffectiveAIPrompt | None = None,
|
||||
) -> list[ParsedNewsItem]:
|
||||
if not items:
|
||||
return items
|
||||
|
||||
semaphore = asyncio.Semaphore(MAX_TARGET_INFERENCE_CONCURRENCY)
|
||||
|
||||
async def enrich(item: ParsedNewsItem) -> ParsedNewsItem:
|
||||
async with semaphore:
|
||||
target = await _infer_news_target_location(
|
||||
item,
|
||||
provider_client=provider_client,
|
||||
prompt=prompt,
|
||||
)
|
||||
item.target_location = target
|
||||
return item
|
||||
|
||||
return list(await asyncio.gather(*(enrich(item) for item in items)))
|
||||
|
||||
|
||||
def get_sources_for_region(region: str) -> list[NewsFeedSource]:
|
||||
return sorted(
|
||||
[source for source in NEWS_FEED_SOURCES if source.region in {"global", region}],
|
||||
@@ -385,23 +889,170 @@ def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
|
||||
published_at = item.published_at
|
||||
def _serialize_anchor(anchor: RegionAnchor) -> dict[str, Any]:
|
||||
return {
|
||||
"region": anchor.region,
|
||||
"label": anchor.label,
|
||||
"latitude": anchor.latitude,
|
||||
"longitude": anchor.longitude,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_target(target: NewsTargetLocation | None) -> dict[str, Any] | None:
|
||||
if target is None:
|
||||
return None
|
||||
return {
|
||||
"latitude": target.latitude,
|
||||
"longitude": target.longitude,
|
||||
"label": target.label,
|
||||
"source": target.source,
|
||||
"confidence": target.confidence,
|
||||
"country": target.country,
|
||||
"city": target.city,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_enriched_at(value: datetime | None) -> str | None:
|
||||
return value.isoformat().replace("+00:00", "Z") if value else None
|
||||
|
||||
|
||||
def _content_patch(item: ParsedNewsItem) -> dict[str, Any]:
|
||||
return {
|
||||
"content_language": item.content_language,
|
||||
"localizations": item.localizations,
|
||||
"enrichment_status": item.enrichment_status,
|
||||
"enrichment_error": item.enrichment_error,
|
||||
"enriched_at": _serialize_enriched_at(item.enriched_at),
|
||||
}
|
||||
|
||||
|
||||
def build_anchor_location_patch(
|
||||
item: ParsedNewsItem,
|
||||
*,
|
||||
queued: bool = False,
|
||||
queue_available: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
anchor = get_region_anchor(item.feed_region)
|
||||
if queued:
|
||||
resolution_stage = "queued"
|
||||
ai_status = "queued"
|
||||
debug_note = "queued for async target location inference"
|
||||
else:
|
||||
resolution_stage = item.target_resolution_stage
|
||||
ai_status = item.target_ai_status
|
||||
debug_note = item.target_debug_note
|
||||
content_patch = _content_patch(item)
|
||||
if queued and content_patch["enrichment_status"] == "pending":
|
||||
content_patch["enrichment_status"] = "queued"
|
||||
return {
|
||||
"latitude": anchor.latitude,
|
||||
"longitude": anchor.longitude,
|
||||
"location_label": anchor.label,
|
||||
"location_source": "region_anchor",
|
||||
"verified": False,
|
||||
"location_meta": {
|
||||
"resolution_stage": resolution_stage,
|
||||
"ai_attempted": item.target_ai_attempted,
|
||||
"ai_status": ai_status,
|
||||
"ai_error": item.target_ai_error,
|
||||
"debug_note": debug_note,
|
||||
"queue_available": queue_available,
|
||||
"target": None,
|
||||
"anchor": _serialize_anchor(anchor),
|
||||
},
|
||||
**content_patch,
|
||||
}
|
||||
|
||||
|
||||
def build_target_location_patch(item: ParsedNewsItem, target: NewsTargetLocation | None) -> dict[str, Any]:
|
||||
if target is None:
|
||||
return build_anchor_location_patch(item)
|
||||
anchor = get_region_anchor(item.feed_region)
|
||||
return {
|
||||
"latitude": target.latitude,
|
||||
"longitude": target.longitude,
|
||||
"location_label": target.label,
|
||||
"location_source": target.source,
|
||||
"verified": True,
|
||||
"location_meta": {
|
||||
"resolution_stage": item.target_resolution_stage,
|
||||
"ai_attempted": item.target_ai_attempted,
|
||||
"ai_status": item.target_ai_status,
|
||||
"ai_error": item.target_ai_error,
|
||||
"debug_note": item.target_debug_note,
|
||||
"target": _serialize_target(target),
|
||||
"anchor": _serialize_anchor(anchor),
|
||||
},
|
||||
**_content_patch(item),
|
||||
}
|
||||
|
||||
|
||||
def build_target_location_job_payload(item: ParsedNewsItem) -> dict[str, Any]:
|
||||
published_at = item.published_at
|
||||
return {
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"summary": item.summary,
|
||||
"content_language": item.content_language,
|
||||
"localizations": item.localizations,
|
||||
"enrichment_status": item.enrichment_status,
|
||||
"enrichment_error": item.enrichment_error,
|
||||
"enriched_at": _serialize_enriched_at(item.enriched_at),
|
||||
"url": item.url,
|
||||
"source": item.source,
|
||||
"feed_name": item.feed_name,
|
||||
"feed_region": item.feed_region,
|
||||
"homepage_url": item.homepage_url,
|
||||
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
|
||||
}
|
||||
|
||||
|
||||
def parsed_news_item_from_job_payload(payload: dict[str, Any]) -> ParsedNewsItem:
|
||||
return ParsedNewsItem(
|
||||
id=str(payload.get("id") or ""),
|
||||
title=str(payload.get("title") or ""),
|
||||
summary=str(payload.get("summary") or ""),
|
||||
content_language=str(payload.get("content_language") or "en"),
|
||||
localizations=_normalize_localizations(payload.get("localizations")),
|
||||
enrichment_status=str(payload.get("enrichment_status") or "pending"),
|
||||
enrichment_error=_coerce_str(payload.get("enrichment_error")),
|
||||
enriched_at=_parse_datetime(_coerce_str(payload.get("enriched_at"))),
|
||||
url=str(payload.get("url") or ""),
|
||||
source=str(payload.get("source") or ""),
|
||||
feed_name=str(payload.get("feed_name") or ""),
|
||||
feed_region=str(payload.get("feed_region") or "global"),
|
||||
homepage_url=str(payload.get("homepage_url") or ""),
|
||||
published_at=_parse_datetime(_coerce_str(payload.get("published_at"))),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
|
||||
published_at = item.published_at
|
||||
location_patch = item.location_patch or build_target_location_patch(item, item.target_location)
|
||||
return {
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"summary": item.summary,
|
||||
"content_language": item.content_language,
|
||||
"localizations": item.localizations,
|
||||
"display_title": _get_locale_text(item, "title"),
|
||||
"display_summary": _get_locale_text(item, "summary"),
|
||||
"url": item.url,
|
||||
"source": item.source,
|
||||
"feed_name": item.feed_name,
|
||||
"region": item.feed_region,
|
||||
"display_region": get_region_anchor(item.feed_region).label,
|
||||
"homepage_url": item.homepage_url,
|
||||
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
|
||||
"latitude": anchor.latitude,
|
||||
"longitude": anchor.longitude,
|
||||
"location_label": anchor.label,
|
||||
"location_inferred": True,
|
||||
"latitude": location_patch["latitude"],
|
||||
"longitude": location_patch["longitude"],
|
||||
"location_label": location_patch["location_label"],
|
||||
"location_source": location_patch["location_source"],
|
||||
"verified": location_patch["verified"],
|
||||
"location_meta": location_patch["location_meta"],
|
||||
"enrichment_status": item.enrichment_status,
|
||||
"enrichment_error": item.enrichment_error,
|
||||
"enriched_at": _serialize_enriched_at(item.enriched_at),
|
||||
"is_focus_match": item.feed_region == active_region,
|
||||
}
|
||||
|
||||
@@ -426,6 +1077,7 @@ def _build_payload(
|
||||
"lon": lon,
|
||||
"region": active_region,
|
||||
"label": profile.label,
|
||||
"display_region": get_region_anchor(active_region).label,
|
||||
"accent": profile.accent,
|
||||
},
|
||||
"sources": _serialize_sources(sources),
|
||||
@@ -472,6 +1124,68 @@ def _store_region_cache(region: str, *, items: list[ParsedNewsItem], sources: li
|
||||
)
|
||||
|
||||
|
||||
async def _apply_cached_locations_and_enqueue(items: list[ParsedNewsItem]) -> list[ParsedNewsItem]:
|
||||
if not items:
|
||||
return items
|
||||
|
||||
from app.services.earth_news_queue import (
|
||||
enqueue_target_location_job,
|
||||
get_cached_target_location_patch,
|
||||
)
|
||||
|
||||
async def enqueue_item(item: ParsedNewsItem, *, force: bool = False) -> bool:
|
||||
return await enqueue_target_location_job(build_target_location_job_payload(item), force=force)
|
||||
|
||||
async def apply_location(item: ParsedNewsItem) -> ParsedNewsItem:
|
||||
cached_patch = await get_cached_target_location_patch(item.id)
|
||||
if cached_patch:
|
||||
apply_enrichment_patch_to_item(item, cached_patch)
|
||||
if not _has_default_localization(item):
|
||||
queued = await enqueue_item(item, force=True)
|
||||
if queued and item.enrichment_status in {
|
||||
"pending",
|
||||
"unavailable",
|
||||
"provider_error",
|
||||
"parse_error",
|
||||
"no_result",
|
||||
"location_only",
|
||||
}:
|
||||
item.enrichment_status = "queued"
|
||||
return item
|
||||
|
||||
queued = await enqueue_item(item)
|
||||
item.location_patch = build_anchor_location_patch(
|
||||
item,
|
||||
queued=queued,
|
||||
queue_available=queued,
|
||||
)
|
||||
return item
|
||||
|
||||
return list(await asyncio.gather(*(apply_location(item) for item in items)))
|
||||
|
||||
|
||||
async def _enqueue_unverified_locations(items: list[ParsedNewsItem]) -> None:
|
||||
if not items:
|
||||
return
|
||||
|
||||
from app.services.earth_news_queue import enqueue_target_location_job
|
||||
|
||||
await asyncio.gather(
|
||||
*(
|
||||
enqueue_target_location_job(
|
||||
build_target_location_job_payload(item),
|
||||
force=not _has_default_localization(item),
|
||||
)
|
||||
for item in items
|
||||
if (
|
||||
item.location_patch is None
|
||||
or item.location_patch.get("verified") is False
|
||||
or not _has_default_localization(item)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_source(
|
||||
client: httpx.AsyncClient,
|
||||
source: NewsFeedSource,
|
||||
@@ -484,11 +1198,10 @@ async def _fetch_source(
|
||||
return source, [], str(exc)
|
||||
|
||||
|
||||
async def get_earth_news_payload(lat: float | None = None, lon: float | None = None) -> dict[str, Any]:
|
||||
active_region = determine_focus_region(lat, lon)
|
||||
sources = get_sources_for_region(active_region)
|
||||
async def _fetch_rss_items_for_sources(
|
||||
sources: list[NewsFeedSource],
|
||||
) -> tuple[list[ParsedNewsItem], list[str]]:
|
||||
errors: list[str] = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
@@ -502,9 +1215,29 @@ async def get_earth_news_payload(lat: float | None = None, lon: float | None = N
|
||||
errors.append(f"{source.name}: {error}")
|
||||
continue
|
||||
fetched_items.extend(items)
|
||||
return fetched_items, errors
|
||||
|
||||
|
||||
def _needs_rss_supplement(*, item_count: int, newest_at: datetime | None) -> bool:
|
||||
if item_count < MAX_ITEMS_TOTAL:
|
||||
return True
|
||||
if newest_at is None:
|
||||
return True
|
||||
age_seconds = (datetime.now(UTC) - newest_at).total_seconds()
|
||||
return age_seconds > RSS_SUPPLEMENT_MAX_AGE_SECONDS
|
||||
|
||||
|
||||
async def _get_earth_news_payload_from_rss_only(
|
||||
*,
|
||||
lat: float | None,
|
||||
lon: float | None,
|
||||
active_region: str,
|
||||
sources: list[NewsFeedSource],
|
||||
) -> dict[str, Any]:
|
||||
fetched_items, errors = await _fetch_rss_items_for_sources(sources)
|
||||
ranked_items = _rank_and_trim_items(fetched_items, active_region=active_region)
|
||||
if ranked_items:
|
||||
ranked_items = await _apply_cached_locations_and_enqueue(ranked_items)
|
||||
_store_region_cache(active_region, items=ranked_items, sources=sources)
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
@@ -518,6 +1251,7 @@ async def get_earth_news_payload(lat: float | None = None, lon: float | None = N
|
||||
|
||||
cached = _get_cached_region_feed(active_region)
|
||||
if cached:
|
||||
cached.items = await _apply_cached_locations_and_enqueue(cached.items)
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
@@ -538,3 +1272,55 @@ async def get_earth_news_payload(lat: float | None = None, lon: float | None = N
|
||||
errors=errors,
|
||||
stale=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_earth_news_payload(
|
||||
lat: float | None = None,
|
||||
lon: float | None = None,
|
||||
*,
|
||||
provider_client: AIProviderClient | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> dict[str, Any]:
|
||||
del provider_client
|
||||
active_region = determine_focus_region(lat, lon)
|
||||
sources = get_sources_for_region(active_region)
|
||||
|
||||
if db is None:
|
||||
return await _get_earth_news_payload_from_rss_only(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
sources=sources,
|
||||
)
|
||||
|
||||
from app.services.earth_news_store import (
|
||||
get_earth_news_freshness,
|
||||
list_earth_news_items,
|
||||
upsert_earth_news_items,
|
||||
)
|
||||
|
||||
errors: list[str] = []
|
||||
item_count, newest_at = await get_earth_news_freshness(db, active_region=active_region)
|
||||
should_supplement = _needs_rss_supplement(item_count=item_count, newest_at=newest_at)
|
||||
if should_supplement:
|
||||
fetched_items, errors = await _fetch_rss_items_for_sources(sources)
|
||||
ranked_fetched_items = _rank_and_trim_items(fetched_items, active_region=active_region)
|
||||
await upsert_earth_news_items(db, ranked_fetched_items)
|
||||
|
||||
items = await list_earth_news_items(
|
||||
db,
|
||||
active_region=active_region,
|
||||
limit=MAX_ITEMS_TOTAL,
|
||||
)
|
||||
await _enqueue_unverified_locations(items)
|
||||
stale = bool(errors and items)
|
||||
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
items=items,
|
||||
sources=sources,
|
||||
errors=errors,
|
||||
stale=stale,
|
||||
)
|
||||
|
||||
234
backend/app/services/earth_news_queue.py
Normal file
234
backend/app/services/earth_news_queue.py
Normal file
@@ -0,0 +1,234 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Any, Protocol
|
||||
|
||||
import redis.asyncio as redis
|
||||
from redis.exceptions import ResponseError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
|
||||
|
||||
logger = get_logger(__name__, service="earth_news")
|
||||
|
||||
TARGET_LOCATION_STREAM = "earth_news:target_location:jobs"
|
||||
TARGET_LOCATION_GROUP = "earth_news_target_location"
|
||||
TARGET_LOCATION_DEAD_LETTER_STREAM = "earth_news:target_location:dead"
|
||||
TARGET_LOCATION_RESULT_TTL_SECONDS = 60 * 60 * 12
|
||||
TARGET_LOCATION_JOB_DEDUP_TTL_SECONDS = 60 * 60 * 6
|
||||
TARGET_LOCATION_MAX_ATTEMPTS = 3
|
||||
|
||||
_redis_client: redis.Redis | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NewsTargetLocationMessage:
|
||||
message_id: str
|
||||
item_id: str
|
||||
payload: dict[str, Any]
|
||||
attempts: int = 0
|
||||
|
||||
|
||||
class NewsTargetLocationQueue(Protocol):
|
||||
async def enqueue(self, *, item_id: str, payload: dict[str, Any], force: bool = False) -> bool:
|
||||
...
|
||||
|
||||
async def consume_batch(
|
||||
self,
|
||||
*,
|
||||
consumer_name: str,
|
||||
count: int,
|
||||
block_ms: int,
|
||||
) -> list[NewsTargetLocationMessage]:
|
||||
...
|
||||
|
||||
async def ack(self, message_id: str) -> None:
|
||||
...
|
||||
|
||||
async def retry_or_dead_letter(
|
||||
self,
|
||||
message: NewsTargetLocationMessage,
|
||||
*,
|
||||
error: str,
|
||||
) -> None:
|
||||
...
|
||||
|
||||
|
||||
def _get_redis_client() -> redis.Redis:
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
_redis_client = redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
return _redis_client
|
||||
|
||||
|
||||
def _result_key(item_id: str) -> str:
|
||||
return f"earth_news:target_location:result:{item_id}"
|
||||
|
||||
|
||||
def _queued_key(item_id: str) -> str:
|
||||
return f"earth_news:target_location:queued:{item_id}"
|
||||
|
||||
|
||||
class RedisStreamsNewsTargetLocationQueue:
|
||||
def __init__(self, client: redis.Redis | None = None) -> None:
|
||||
self.client = client or _get_redis_client()
|
||||
self._group_ready = False
|
||||
|
||||
async def _ensure_group(self) -> None:
|
||||
if self._group_ready:
|
||||
return
|
||||
try:
|
||||
await self.client.xgroup_create(
|
||||
TARGET_LOCATION_STREAM,
|
||||
TARGET_LOCATION_GROUP,
|
||||
id="0",
|
||||
mkstream=True,
|
||||
)
|
||||
except ResponseError as exc:
|
||||
if "BUSYGROUP" not in str(exc):
|
||||
raise
|
||||
self._group_ready = True
|
||||
|
||||
async def enqueue(self, *, item_id: str, payload: dict[str, Any], force: bool = False) -> bool:
|
||||
await self._ensure_group()
|
||||
if force:
|
||||
await self.client.delete(_result_key(item_id), _queued_key(item_id))
|
||||
elif await self.client.exists(_result_key(item_id)):
|
||||
return False
|
||||
queued = await self.client.set(
|
||||
_queued_key(item_id),
|
||||
"1",
|
||||
nx=True,
|
||||
ex=TARGET_LOCATION_JOB_DEDUP_TTL_SECONDS,
|
||||
)
|
||||
if not queued:
|
||||
return bool(await self.client.exists(_queued_key(item_id)))
|
||||
await self.client.xadd(
|
||||
TARGET_LOCATION_STREAM,
|
||||
{
|
||||
"item_id": item_id,
|
||||
"attempts": "0",
|
||||
"payload": json.dumps(payload, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
async def consume_batch(
|
||||
self,
|
||||
*,
|
||||
consumer_name: str,
|
||||
count: int,
|
||||
block_ms: int,
|
||||
) -> list[NewsTargetLocationMessage]:
|
||||
await self._ensure_group()
|
||||
streams = await self.client.xreadgroup(
|
||||
TARGET_LOCATION_GROUP,
|
||||
consumer_name,
|
||||
{TARGET_LOCATION_STREAM: ">"},
|
||||
count=count,
|
||||
block=block_ms,
|
||||
)
|
||||
messages: list[NewsTargetLocationMessage] = []
|
||||
for _stream_name, stream_messages in streams:
|
||||
for message_id, fields in stream_messages:
|
||||
raw_payload = fields.get("payload")
|
||||
item_id = fields.get("item_id")
|
||||
if not raw_payload or not item_id:
|
||||
await self.ack(message_id)
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(raw_payload)
|
||||
except json.JSONDecodeError:
|
||||
await self.ack(message_id)
|
||||
continue
|
||||
attempts = int(fields.get("attempts") or 0)
|
||||
messages.append(
|
||||
NewsTargetLocationMessage(
|
||||
message_id=message_id,
|
||||
item_id=item_id,
|
||||
payload=payload,
|
||||
attempts=attempts,
|
||||
)
|
||||
)
|
||||
return messages
|
||||
|
||||
async def ack(self, message_id: str) -> None:
|
||||
await self.client.xack(TARGET_LOCATION_STREAM, TARGET_LOCATION_GROUP, message_id)
|
||||
|
||||
async def retry_or_dead_letter(
|
||||
self,
|
||||
message: NewsTargetLocationMessage,
|
||||
*,
|
||||
error: str,
|
||||
) -> None:
|
||||
await self.ack(message.message_id)
|
||||
if message.attempts + 1 >= TARGET_LOCATION_MAX_ATTEMPTS:
|
||||
await self.client.xadd(
|
||||
TARGET_LOCATION_DEAD_LETTER_STREAM,
|
||||
{
|
||||
"item_id": message.item_id,
|
||||
"attempts": str(message.attempts + 1),
|
||||
"error": error,
|
||||
"payload": json.dumps(message.payload, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
return
|
||||
await self.client.xadd(
|
||||
TARGET_LOCATION_STREAM,
|
||||
{
|
||||
"item_id": message.item_id,
|
||||
"attempts": str(message.attempts + 1),
|
||||
"payload": json.dumps(message.payload, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_news_target_location_queue() -> NewsTargetLocationQueue:
|
||||
return RedisStreamsNewsTargetLocationQueue()
|
||||
|
||||
|
||||
async def enqueue_target_location_job(payload: dict[str, Any], *, force: bool = False) -> bool:
|
||||
item_id = str(payload.get("id") or "")
|
||||
if not item_id:
|
||||
return False
|
||||
try:
|
||||
queue = get_news_target_location_queue()
|
||||
return await queue.enqueue(item_id=item_id, payload=payload, force=force)
|
||||
except Exception as exc:
|
||||
logger.warning_event(
|
||||
"Failed to enqueue Earth news target location job",
|
||||
event="earth_news.target_location.enqueue_failed",
|
||||
context={"item_id": item_id, "error": str(exc)},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def get_cached_target_location_patch(item_id: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
raw_value = await _get_redis_client().get(_result_key(item_id))
|
||||
except Exception as exc:
|
||||
logger.warning_event(
|
||||
"Failed to read Earth news target location cache",
|
||||
event="earth_news.target_location.cache_read_failed",
|
||||
context={"item_id": item_id, "error": str(exc)},
|
||||
)
|
||||
return None
|
||||
if not raw_value:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(raw_value)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
async def save_target_location_patch(item_id: str, patch: dict[str, Any]) -> None:
|
||||
client = _get_redis_client()
|
||||
await client.setex(
|
||||
_result_key(item_id),
|
||||
TARGET_LOCATION_RESULT_TTL_SECONDS,
|
||||
json.dumps(patch, ensure_ascii=False),
|
||||
)
|
||||
await client.delete(_queued_key(item_id))
|
||||
244
backend/app/services/earth_news_store.py
Normal file
244
backend/app/services/earth_news_store.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.earth_news import EarthNewsItem
|
||||
from app.services.earth_news import (
|
||||
ParsedNewsItem,
|
||||
apply_enrichment_patch_to_item,
|
||||
build_anchor_location_patch,
|
||||
)
|
||||
|
||||
|
||||
def _coerce_datetime(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
def _location_patch_from_record(record: EarthNewsItem) -> dict[str, Any]:
|
||||
return {
|
||||
"latitude": record.latitude,
|
||||
"longitude": record.longitude,
|
||||
"location_label": record.location_label,
|
||||
"location_source": record.location_source,
|
||||
"verified": record.verified,
|
||||
"location_meta": dict(record.location_meta or {}),
|
||||
}
|
||||
|
||||
|
||||
def record_to_parsed_news_item(record: EarthNewsItem) -> ParsedNewsItem:
|
||||
item = ParsedNewsItem(
|
||||
id=record.id,
|
||||
title=record.title,
|
||||
summary=record.summary or "",
|
||||
url=record.url,
|
||||
source=record.source or "",
|
||||
feed_name=record.feed_name or "",
|
||||
feed_region=record.region or "global",
|
||||
homepage_url=record.homepage_url or "",
|
||||
published_at=_coerce_datetime(record.published_at),
|
||||
content_language=record.content_language or "en",
|
||||
localizations=dict(record.localizations or {}),
|
||||
enrichment_status=record.enrichment_status or "pending",
|
||||
enrichment_error=record.enrichment_error,
|
||||
enriched_at=_coerce_datetime(record.enriched_at),
|
||||
)
|
||||
return apply_enrichment_patch_to_item(item, _location_patch_from_record(record))
|
||||
|
||||
|
||||
def _query_sort_key(active_region: str):
|
||||
return (
|
||||
EarthNewsItem.region != active_region,
|
||||
EarthNewsItem.published_at.is_(None),
|
||||
EarthNewsItem.published_at.desc().nullslast(),
|
||||
EarthNewsItem.feed_name.asc(),
|
||||
)
|
||||
|
||||
|
||||
async def list_earth_news_items(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
active_region: str,
|
||||
limit: int,
|
||||
) -> list[ParsedNewsItem]:
|
||||
regions = {"global", active_region}
|
||||
result = await db.execute(
|
||||
select(EarthNewsItem)
|
||||
.where(EarthNewsItem.region.in_(regions))
|
||||
.order_by(*_query_sort_key(active_region))
|
||||
.limit(limit)
|
||||
)
|
||||
return [record_to_parsed_news_item(record) for record in result.scalars().all()]
|
||||
|
||||
|
||||
async def get_earth_news_freshness(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
active_region: str,
|
||||
) -> tuple[int, datetime | None]:
|
||||
regions = {"global", active_region}
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count(EarthNewsItem.id),
|
||||
func.max(func.coalesce(EarthNewsItem.published_at, EarthNewsItem.last_seen_at)),
|
||||
).where(EarthNewsItem.region.in_(regions))
|
||||
)
|
||||
count, newest = result.one()
|
||||
item_count = int(count or 0)
|
||||
if item_count == 0:
|
||||
return 0, None
|
||||
return item_count, _coerce_datetime(newest)
|
||||
|
||||
|
||||
async def upsert_earth_news_items(db: AsyncSession, items: list[ParsedNewsItem]) -> int:
|
||||
if not items:
|
||||
return 0
|
||||
now = datetime.now(UTC)
|
||||
existing_result = await db.execute(
|
||||
select(EarthNewsItem).where(EarthNewsItem.id.in_([item.id for item in items]))
|
||||
)
|
||||
existing = {record.id: record for record in existing_result.scalars().all()}
|
||||
changed = 0
|
||||
for item in items:
|
||||
record = existing.get(item.id)
|
||||
if record is None:
|
||||
patch = build_anchor_location_patch(item)
|
||||
record = EarthNewsItem(
|
||||
id=item.id,
|
||||
title=item.title,
|
||||
summary=item.summary,
|
||||
content_language=item.content_language,
|
||||
localizations=dict(item.localizations or {}),
|
||||
url=item.url,
|
||||
source=item.source,
|
||||
feed_name=item.feed_name,
|
||||
region=item.feed_region,
|
||||
homepage_url=item.homepage_url,
|
||||
published_at=item.published_at,
|
||||
latitude=patch["latitude"],
|
||||
longitude=patch["longitude"],
|
||||
location_label=patch["location_label"],
|
||||
location_source=patch["location_source"],
|
||||
verified=patch["verified"],
|
||||
location_meta=patch["location_meta"],
|
||||
first_seen_at=now,
|
||||
last_seen_at=now,
|
||||
enrichment_status=item.enrichment_status,
|
||||
enrichment_error=item.enrichment_error,
|
||||
enriched_at=item.enriched_at,
|
||||
)
|
||||
db.add(record)
|
||||
changed += 1
|
||||
continue
|
||||
|
||||
record.title = item.title
|
||||
record.summary = item.summary
|
||||
record.url = item.url
|
||||
record.source = item.source
|
||||
record.feed_name = item.feed_name
|
||||
record.region = item.feed_region
|
||||
record.homepage_url = item.homepage_url
|
||||
record.published_at = item.published_at
|
||||
record.last_seen_at = now
|
||||
if item.localizations:
|
||||
record.content_language = item.content_language
|
||||
record.localizations = dict(item.localizations or {})
|
||||
record.enrichment_status = item.enrichment_status
|
||||
record.enrichment_error = item.enrichment_error
|
||||
record.enriched_at = item.enriched_at
|
||||
changed += 1
|
||||
await db.flush()
|
||||
return changed
|
||||
|
||||
|
||||
async def update_earth_news_item_location(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
item_id: str,
|
||||
patch: dict[str, Any],
|
||||
) -> bool:
|
||||
record = await db.get(EarthNewsItem, item_id)
|
||||
if record is None:
|
||||
return False
|
||||
record.latitude = float(patch["latitude"])
|
||||
record.longitude = float(patch["longitude"])
|
||||
record.location_label = str(patch["location_label"])
|
||||
record.location_source = str(patch["location_source"])
|
||||
record.verified = bool(patch["verified"])
|
||||
record.location_meta = dict(patch.get("location_meta") or {})
|
||||
record.resolved_at = datetime.now(UTC) if record.verified else None
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def update_earth_news_item_enrichment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
item_id: str,
|
||||
patch: dict[str, Any],
|
||||
) -> bool:
|
||||
record = await db.get(EarthNewsItem, item_id)
|
||||
if record is None:
|
||||
return False
|
||||
if "latitude" in patch:
|
||||
record.latitude = float(patch["latitude"])
|
||||
record.longitude = float(patch["longitude"])
|
||||
record.location_label = str(patch["location_label"])
|
||||
record.location_source = str(patch["location_source"])
|
||||
record.verified = bool(patch["verified"])
|
||||
record.location_meta = dict(patch.get("location_meta") or {})
|
||||
record.resolved_at = datetime.now(UTC) if record.verified else None
|
||||
if "content_language" in patch:
|
||||
record.content_language = str(patch.get("content_language") or "en")
|
||||
if "localizations" in patch:
|
||||
record.localizations = dict(patch.get("localizations") or {})
|
||||
if "enrichment_status" in patch:
|
||||
record.enrichment_status = str(patch.get("enrichment_status") or "pending")
|
||||
if "enrichment_error" in patch:
|
||||
record.enrichment_error = patch.get("enrichment_error")
|
||||
if patch.get("enriched_at"):
|
||||
try:
|
||||
parsed_enriched_at = datetime.fromisoformat(
|
||||
str(patch["enriched_at"]).replace("Z", "+00:00")
|
||||
)
|
||||
except ValueError:
|
||||
parsed_enriched_at = datetime.now(UTC)
|
||||
record.enriched_at = _coerce_datetime(parsed_enriched_at)
|
||||
elif patch.get("localizations"):
|
||||
record.enriched_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def list_unverified_earth_news_items(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
active_region: str,
|
||||
limit: int,
|
||||
) -> list[ParsedNewsItem]:
|
||||
regions = {"global", active_region}
|
||||
result = await db.execute(
|
||||
select(EarthNewsItem)
|
||||
.where(EarthNewsItem.region.in_(regions))
|
||||
.where(EarthNewsItem.verified.is_(False))
|
||||
.order_by(*_query_sort_key(active_region))
|
||||
.limit(limit)
|
||||
)
|
||||
return [record_to_parsed_news_item(record) for record in result.scalars().all()]
|
||||
|
||||
|
||||
async def list_all_earth_news_records(db: AsyncSession) -> list[EarthNewsItem]:
|
||||
result = await db.execute(
|
||||
select(EarthNewsItem).order_by(
|
||||
EarthNewsItem.published_at.desc().nullslast(),
|
||||
EarthNewsItem.last_seen_at.desc(),
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
142
backend/app/services/earth_news_worker.py
Normal file
142
backend/app/services/earth_news_worker.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from socket import gethostname
|
||||
from typing import Any
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.core.websocket.broadcaster import broadcaster
|
||||
from app.db.session import async_session_factory
|
||||
from app.services.ai_client import AIProviderClient
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
from app.services.earth_news import (
|
||||
NEWS_ENRICH_PROMPT_KEY,
|
||||
_infer_news_enrichment,
|
||||
build_target_location_patch,
|
||||
parsed_news_item_from_job_payload,
|
||||
)
|
||||
from app.services.earth_news_queue import (
|
||||
NewsTargetLocationMessage,
|
||||
get_news_target_location_queue,
|
||||
save_target_location_patch,
|
||||
)
|
||||
from app.services.earth_news_store import update_earth_news_item_enrichment as update_earth_news_item_location
|
||||
|
||||
|
||||
logger = get_logger(__name__, service="earth_news")
|
||||
|
||||
WORKER_BATCH_SIZE = 4
|
||||
WORKER_BLOCK_MS = 5000
|
||||
WORKER_BACKOFF_SECONDS = 5.0
|
||||
|
||||
_worker_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
async def _build_provider_client() -> AIProviderClient | None:
|
||||
try:
|
||||
from app.api.v1.settings import get_runtime_ai_provider_config
|
||||
|
||||
async with async_session_factory() as session:
|
||||
runtime_config = await get_runtime_ai_provider_config(session)
|
||||
return 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 {},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning_event(
|
||||
"Failed to build Earth news AI provider client",
|
||||
event="earth_news.target_location.provider_unavailable",
|
||||
context={"error": str(exc)},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def process_target_location_message(
|
||||
message: NewsTargetLocationMessage,
|
||||
*,
|
||||
provider_client: AIProviderClient | None,
|
||||
) -> dict[str, Any]:
|
||||
item = parsed_news_item_from_job_payload(message.payload)
|
||||
async with async_session_factory() as session:
|
||||
prompt = await get_effective_prompt(session, NEWS_ENRICH_PROMPT_KEY)
|
||||
target, localizations = await _infer_news_enrichment(
|
||||
item,
|
||||
provider_client=provider_client,
|
||||
prompt=prompt,
|
||||
)
|
||||
item.target_location = target
|
||||
item.localizations = localizations or item.localizations
|
||||
patch = build_target_location_patch(item, target)
|
||||
await save_target_location_patch(item.id, patch)
|
||||
async with async_session_factory() as session:
|
||||
await update_earth_news_item_location(session, item_id=item.id, patch=patch)
|
||||
await session.commit()
|
||||
await broadcaster.broadcast_custom(
|
||||
"earth_news",
|
||||
{
|
||||
"item_id": item.id,
|
||||
"patch": patch,
|
||||
},
|
||||
)
|
||||
return patch
|
||||
|
||||
|
||||
async def _run_target_location_worker() -> None:
|
||||
consumer_name = f"{gethostname()}:{id(asyncio.current_task())}"
|
||||
queue = get_news_target_location_queue()
|
||||
while True:
|
||||
try:
|
||||
messages = await queue.consume_batch(
|
||||
consumer_name=consumer_name,
|
||||
count=WORKER_BATCH_SIZE,
|
||||
block_ms=WORKER_BLOCK_MS,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning_event(
|
||||
"Earth news target location worker queue read failed",
|
||||
event="earth_news.target_location.worker_read_failed",
|
||||
context={"error": str(exc)},
|
||||
)
|
||||
await asyncio.sleep(WORKER_BACKOFF_SECONDS)
|
||||
continue
|
||||
|
||||
if not messages:
|
||||
continue
|
||||
provider_client = await _build_provider_client()
|
||||
for message in messages:
|
||||
try:
|
||||
await process_target_location_message(message, provider_client=provider_client)
|
||||
await queue.ack(message.message_id)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning_event(
|
||||
"Earth news target location worker job failed",
|
||||
event="earth_news.target_location.worker_job_failed",
|
||||
context={"item_id": message.item_id, "error": str(exc)},
|
||||
)
|
||||
with suppress(Exception):
|
||||
await queue.retry_or_dead_letter(message, error=str(exc))
|
||||
|
||||
|
||||
def start_earth_news_target_worker() -> None:
|
||||
global _worker_task
|
||||
if _worker_task is None or _worker_task.done():
|
||||
_worker_task = asyncio.create_task(_run_target_location_worker())
|
||||
|
||||
|
||||
async def stop_earth_news_target_worker() -> None:
|
||||
global _worker_task
|
||||
task = _worker_task
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
_worker_task = None
|
||||
@@ -7,8 +7,11 @@ import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.countries import COUNTRY_ENTRIES, normalize_country
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
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
|
||||
@@ -23,6 +26,8 @@ from app.services.location.text import (
|
||||
|
||||
VALID_LLM_PRECISIONS = {"precise", "site", "city"}
|
||||
DEFAULT_MIN_CONFIDENCE = 0.55
|
||||
LOCATION_NORMALIZE_PROMPT_KEY = "location.factcheck.normalize"
|
||||
LOCATION_RESOLVE_PROMPT_KEY = "location.factcheck.resolve"
|
||||
MODEL_CONFIDENCE_WEIGHT = 0.25
|
||||
_geocode_llm_city = build_default_nominatim_geocoder()
|
||||
_LLM_LOCATION_NAME_KEYS = (
|
||||
@@ -876,6 +881,7 @@ async def _repair_location_payload_from_text(
|
||||
raw_text: str,
|
||||
query: LocationQuery,
|
||||
entity_type: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Second-pass structure repair for models that answer in prose.
|
||||
|
||||
@@ -884,12 +890,11 @@ async def _repair_location_payload_from_text(
|
||||
"""
|
||||
if not coerce_str(raw_text):
|
||||
return None
|
||||
prompt = await get_effective_prompt(db, LOCATION_NORMALIZE_PROMPT_KEY)
|
||||
request = SituationalAnalysisRequest(
|
||||
title=f"Normalize location factcheck for {entity_type}",
|
||||
objective=(
|
||||
"Convert the supplied location factcheck text into exactly one strict "
|
||||
"JSON object. Extract only facts present in the text or original query."
|
||||
),
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
context={
|
||||
"entity_type": entity_type,
|
||||
"location_query": _query_context(query),
|
||||
@@ -929,6 +934,7 @@ async def collect_llm_location_fallback_candidate(
|
||||
provider_client: AIProviderClient,
|
||||
query: LocationQuery,
|
||||
entity_type: str,
|
||||
db: AsyncSession | None = None,
|
||||
attempted_queries: Iterable[str] = (),
|
||||
search_evidence: list[dict[str, Any]] | None = None,
|
||||
min_confidence: float = DEFAULT_MIN_CONFIDENCE,
|
||||
@@ -946,13 +952,11 @@ async def collect_llm_location_fallback_candidate(
|
||||
attempted_queries=[attempt],
|
||||
failure_reason="LLM location factcheck skipped: no WebSearch evidence.",
|
||||
)
|
||||
prompt = await get_effective_prompt(db, LOCATION_RESOLVE_PROMPT_KEY)
|
||||
request = SituationalAnalysisRequest(
|
||||
title=f"Location factcheck fallback for {entity_type}",
|
||||
objective=(
|
||||
"Return exactly one JSON object for the most likely physical location. "
|
||||
"Use only fact-checkable public knowledge; return null fields rather "
|
||||
"than guessing when evidence is weak."
|
||||
),
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
context={
|
||||
"entity_type": entity_type,
|
||||
"location_query": _query_context(query),
|
||||
@@ -1001,6 +1005,7 @@ async def collect_llm_location_fallback_candidate(
|
||||
raw_text=response.content,
|
||||
query=query,
|
||||
entity_type=entity_type,
|
||||
db=db,
|
||||
)
|
||||
if payload is None:
|
||||
payload = _payload_from_free_text(response.content, query=query)
|
||||
|
||||
@@ -175,19 +175,29 @@ async def run_collector_task(collector_name: str):
|
||||
)
|
||||
|
||||
try:
|
||||
collector._datasource_id = datasource.id
|
||||
datasource_id = datasource.id
|
||||
datasource_source = datasource.source
|
||||
collector._datasource_id = datasource_id
|
||||
logger.info_event(
|
||||
"Running collector",
|
||||
event="collector.run.started",
|
||||
context={"collector_name": collector_name, "datasource_id": datasource.id},
|
||||
context={"collector_name": collector_name, "datasource_id": datasource_id},
|
||||
)
|
||||
task_result = await collector.run(db)
|
||||
datasource = await db.get(DataSource, datasource_id)
|
||||
if datasource is None:
|
||||
logger.error_event(
|
||||
"Datasource disappeared after collector run",
|
||||
event="collector.run.datasource_missing_after_run",
|
||||
context={"collector_name": collector_name, "datasource_id": datasource_id},
|
||||
)
|
||||
return
|
||||
datasource.last_run_at = datetime.now(UTC)
|
||||
datasource.last_status = task_result.get("status")
|
||||
if datasource.last_status == "success":
|
||||
effective_candidate = await get_builtin_effective_candidate(db, datasource.source)
|
||||
effective_candidate = await get_builtin_effective_candidate(db, datasource_source)
|
||||
checksum, _credential_context = await build_builtin_connectivity_checksum(
|
||||
datasource.source,
|
||||
datasource_source,
|
||||
effective_candidate["endpoint"],
|
||||
effective_candidate["auth_type"],
|
||||
effective_candidate["headers"],
|
||||
@@ -196,7 +206,7 @@ async def run_collector_task(collector_name: str):
|
||||
)
|
||||
await save_connectivity_success(
|
||||
db,
|
||||
datasource.source,
|
||||
datasource_source,
|
||||
checksum,
|
||||
{"status_code": None},
|
||||
connected_by="collection",
|
||||
@@ -205,9 +215,11 @@ async def run_collector_task(collector_name: str):
|
||||
logger.info_event(
|
||||
"Collector completed",
|
||||
event="collector.run.completed",
|
||||
context={"collector_name": collector_name, "datasource_id": datasource.id, "result": task_result},
|
||||
context={"collector_name": collector_name, "datasource_id": datasource_id, "result": task_result},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
await db.rollback()
|
||||
datasource = await db.get(DataSource, datasource_id)
|
||||
datasource.last_run_at = datetime.now(UTC)
|
||||
datasource.last_status = "cancelled"
|
||||
await db.commit()
|
||||
@@ -218,6 +230,8 @@ async def run_collector_task(collector_name: str):
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
datasource = await db.get(DataSource, datasource_id)
|
||||
datasource.last_run_at = datetime.now(UTC)
|
||||
datasource.last_status = "failed"
|
||||
await db.commit()
|
||||
|
||||
@@ -10,8 +10,11 @@ from app.models.alert import Alert, AlertSeverity, AlertStatus
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
from app.services.bgp_ai_brief_store import get_latest_bgp_brief_record
|
||||
|
||||
SITUATIONAL_ALERT_BRIEF_PROMPT_KEY = "alerts.situational.brief"
|
||||
|
||||
|
||||
def _format_pairs(pairs: list[tuple[str, int]], empty_text: str = "无") -> str:
|
||||
if not pairs:
|
||||
@@ -96,18 +99,24 @@ async def build_situational_alert_brief_request(
|
||||
(str(item[0] or "未命名数据源"), item[1])
|
||||
for item in alert_source_result.fetchall()
|
||||
]
|
||||
total_alerts = total_alerts_result.scalar() or 0
|
||||
active_alerts = active_alerts_result.scalar() or 0
|
||||
total_incidents = total_incidents_result.scalar() or 0
|
||||
active_incidents = active_incidents_result.scalar() or 0
|
||||
total_anomalies = total_anomalies_result.scalar() or 0
|
||||
active_anomalies = active_anomalies_result.scalar() or 0
|
||||
|
||||
facts = [
|
||||
(
|
||||
f"系统告警侧:总告警 {total_alerts_result.scalar() or 0} 条,active {active_alerts_result.scalar() or 0} 条;"
|
||||
f"系统告警侧:总告警 {total_alerts} 条,active {active_alerts} 条;"
|
||||
f"活跃告警严重度分布为 {_format_pairs(active_alert_severities)}。"
|
||||
),
|
||||
(
|
||||
f"BGP态势侧:累计 incidents {total_incidents_result.scalar() or 0} 条,active incidents {active_incidents_result.scalar() or 0} 条;"
|
||||
f"BGP态势侧:累计 incidents {total_incidents} 条,active incidents {active_incidents} 条;"
|
||||
f"活跃 incidents 严重度分布为 {_format_pairs(active_bgp_severities)}。"
|
||||
),
|
||||
(
|
||||
f"BGP异常侧:累计 anomalies {total_anomalies_result.scalar() or 0} 条,active anomalies {active_anomalies_result.scalar() or 0} 条;"
|
||||
f"BGP异常侧:累计 anomalies {total_anomalies} 条,active anomalies {active_anomalies} 条;"
|
||||
f"活跃 anomaly 类型分布为 {_format_pairs(active_anomaly_types)}。"
|
||||
),
|
||||
]
|
||||
@@ -147,21 +156,23 @@ async def build_situational_alert_brief_request(
|
||||
|
||||
context = {
|
||||
"source": "situational-alerts",
|
||||
"active_system_alerts": active_alerts_result.scalar() or 0,
|
||||
"active_system_alerts": active_alerts,
|
||||
"active_system_alert_severities": dict(active_alert_severities),
|
||||
"top_system_alert_sources": dict(active_alert_sources),
|
||||
"active_bgp_incidents": active_incidents_result.scalar() or 0,
|
||||
"active_bgp_incidents": active_incidents,
|
||||
"active_bgp_incident_severities": dict(active_bgp_severities),
|
||||
"active_bgp_anomalies": active_anomalies_result.scalar() or 0,
|
||||
"active_bgp_anomalies": active_anomalies,
|
||||
"active_bgp_anomaly_types": dict(active_anomaly_types),
|
||||
"bgp_hot_regions": dict(bgp_region_counter.most_common(5)),
|
||||
"latest_bgp_brief_id": latest_bgp_brief.id if latest_bgp_brief else None,
|
||||
"latest_bgp_brief_generated_at": latest_bgp_brief.generated_at if latest_bgp_brief else None,
|
||||
}
|
||||
prompt = await get_effective_prompt(db, SITUATIONAL_ALERT_BRIEF_PROMPT_KEY)
|
||||
|
||||
request = SituationalAnalysisRequest(
|
||||
title="态势告警 AI 简报",
|
||||
objective="综合系统告警、BGP incidents、BGP anomalies 与近期 BGP AI 简报,生成一份面向值班人员的态势告警简报,指出当前最需要关注的风险域、跨模块联动迹象和优先动作。",
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
observations=facts,
|
||||
constraints=[
|
||||
"明确区分事实、推断与建议。",
|
||||
|
||||
@@ -5,10 +5,12 @@ from datetime import datetime
|
||||
from unittest.mock import patch, AsyncMock
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.api.v1 import earth as earth_api
|
||||
from app.main import app
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token
|
||||
from app.db.session import get_db
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.user import User
|
||||
from app.schemas.ai import (
|
||||
AIProviderStatusResponse,
|
||||
@@ -61,6 +63,148 @@ async def test_root_endpoint():
|
||||
assert data["version"] == settings.VERSION
|
||||
|
||||
|
||||
class _ScalarOneOrNoneResult:
|
||||
def __init__(self, value=None):
|
||||
self._value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class _FakeEarthBrandSession:
|
||||
def __init__(self, record=None):
|
||||
self.record = record
|
||||
self.added = None
|
||||
self.deleted = False
|
||||
self.committed = False
|
||||
|
||||
async def execute(self, statement):
|
||||
if statement.__class__.__name__ == "Delete":
|
||||
self.deleted = True
|
||||
self.record = None
|
||||
return _ScalarOneOrNoneResult(None)
|
||||
return _ScalarOneOrNoneResult(self.record)
|
||||
|
||||
def add(self, record):
|
||||
self.added = record
|
||||
self.record = record
|
||||
|
||||
async def commit(self):
|
||||
self.committed = True
|
||||
|
||||
async def refresh(self, _record):
|
||||
return None
|
||||
|
||||
|
||||
def _override_admin_user():
|
||||
return User(id=1, username="testuser", email="test@example.com", role="admin", is_active=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_earth_brand_returns_static_defaults():
|
||||
async def override_get_db():
|
||||
yield _FakeEarthBrandSession()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
try:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/earth/brand")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["is_default"] is True
|
||||
assert data["brand"]["logo_src"] == "/earth/assets/brand/earth-logo.png"
|
||||
assert data["brand"]["title_src"] == "/earth/assets/brand/title-zh.png"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_and_reset_earth_brand(auth_headers):
|
||||
session = _FakeEarthBrandSession()
|
||||
|
||||
async def override_get_db():
|
||||
yield session
|
||||
|
||||
app.dependency_overrides.update(
|
||||
{
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: _override_admin_user,
|
||||
get_db: override_get_db,
|
||||
}
|
||||
)
|
||||
try:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
update_response = await client.put(
|
||||
"/api/v1/earth/brand",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"logo_src": "/earth-brand-assets/custom.png",
|
||||
"title_src": "",
|
||||
"title_text": "Custom Earth",
|
||||
"subtitle": "Custom subtitle",
|
||||
"description": "Custom description",
|
||||
"aria_label": "",
|
||||
"title_alt": "",
|
||||
},
|
||||
)
|
||||
reset_response = await client.delete("/api/v1/earth/brand", headers=auth_headers)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
updated = update_response.json()
|
||||
assert updated["is_default"] is False
|
||||
assert updated["brand"]["title_text"] == "Custom Earth"
|
||||
assert updated["brand"]["aria_label"] == "Custom Earth"
|
||||
assert isinstance(session.added, SystemSetting)
|
||||
assert reset_response.status_code == 200
|
||||
reset = reset_response.json()
|
||||
assert reset["is_default"] is True
|
||||
assert reset["brand"]["logo_src"] == "/earth/assets/brand/earth-logo.png"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_earth_brand_asset_rejects_invalid_type(auth_headers):
|
||||
app.dependency_overrides[
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user
|
||||
] = _override_admin_user
|
||||
try:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/earth/brand/assets",
|
||||
headers=auth_headers,
|
||||
files={"file": ("brand.txt", b"nope", "text/plain")},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "unsupported_file_type"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_earth_brand_asset_saves_file(auth_headers, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(earth_api, "EARTH_BRAND_ASSET_DIR", tmp_path)
|
||||
app.dependency_overrides[
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user
|
||||
] = _override_admin_user
|
||||
try:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/earth/brand/assets",
|
||||
headers=auth_headers,
|
||||
files={"file": ("brand.png", b"png-bytes", "image/png")},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["url"].startswith("/earth-brand-assets/")
|
||||
assert (tmp_path / data["filename"]).read_bytes() == b"png-bytes"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_stats_without_auth():
|
||||
"""Test dashboard stats requires authentication"""
|
||||
|
||||
177
backend/tests/test_earth_boundaries.py
Normal file
177
backend/tests/test_earth_boundaries.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.datasource_defaults import DEFAULT_DATASOURCES
|
||||
from app.services.collectors.registry import collector_registry
|
||||
from app.services import earth_boundaries
|
||||
|
||||
|
||||
def write_geojson(path, name="Test"):
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {"name": name},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def patch_paths(monkeypatch, tmp_path):
|
||||
repo = tmp_path
|
||||
source_dir = repo / "data/earth-boundary-sources"
|
||||
boundary_dir = repo / "frontend/public/earth/data/boundaries/v1"
|
||||
pmtiles = repo / "frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"
|
||||
legacy = repo / "frontend/public/earth/data/countries-admin0.min.geojson"
|
||||
config = repo / "config/earth-boundary-sources.local.json"
|
||||
example = repo / "config/earth-boundary-sources.example.json"
|
||||
policy = repo / "config/earth-boundary-pov-policy.china-v1.json"
|
||||
for path in (source_dir, boundary_dir, pmtiles.parent, legacy.parent, config.parent):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
policy.write_text('{"productionTileFormat":"pmtiles+mvt"}\n', encoding="utf-8")
|
||||
example.write_text('{"collectorConfigs":{}}\n', encoding="utf-8")
|
||||
monkeypatch.setattr(earth_boundaries, "REPO_ROOT", repo)
|
||||
monkeypatch.setattr(earth_boundaries, "SOURCE_OUTPUT_DIR", source_dir)
|
||||
monkeypatch.setattr(earth_boundaries, "SOURCE_MANIFEST_PATH", source_dir / "manifest.json")
|
||||
monkeypatch.setattr(earth_boundaries, "BUILD_RESULT_PATH", source_dir / "build-result.json")
|
||||
monkeypatch.setattr(earth_boundaries, "BUILD_JOB_PATH", source_dir / "build-job.json")
|
||||
monkeypatch.setattr(earth_boundaries, "BOUNDARY_OUTPUT_DIR", boundary_dir)
|
||||
monkeypatch.setattr(earth_boundaries, "BOUNDARY_MANIFEST_PATH", boundary_dir / "manifest.json")
|
||||
monkeypatch.setattr(earth_boundaries, "PMTILES_ARTIFACT_PATH", pmtiles)
|
||||
monkeypatch.setattr(earth_boundaries, "LEGACY_GEOJSON_PATH", legacy)
|
||||
monkeypatch.setattr(earth_boundaries, "LOCAL_CONFIG_PATH", config)
|
||||
monkeypatch.setattr(earth_boundaries, "EXAMPLE_CONFIG_PATH", example)
|
||||
monkeypatch.setattr(earth_boundaries, "POV_POLICY_PATH", policy)
|
||||
return {
|
||||
"repo": repo,
|
||||
"config": config,
|
||||
"legacy": legacy,
|
||||
"pmtiles": pmtiles,
|
||||
"manifest": boundary_dir / "manifest.json",
|
||||
}
|
||||
|
||||
|
||||
def test_boundary_status_uses_legacy_provider_when_pmtiles_missing(monkeypatch, tmp_path):
|
||||
paths = patch_paths(monkeypatch, tmp_path)
|
||||
write_geojson(paths["legacy"])
|
||||
|
||||
status = earth_boundaries.get_boundary_status()
|
||||
|
||||
assert status["provider"] == "legacy-geojson"
|
||||
assert status["fallback_available"] is True
|
||||
assert status["high_precision_ready"] is False
|
||||
|
||||
|
||||
def test_boundary_status_prefers_high_precision_when_manifest_and_pmtiles_exist(monkeypatch, tmp_path):
|
||||
paths = patch_paths(monkeypatch, tmp_path)
|
||||
write_geojson(paths["legacy"])
|
||||
paths["pmtiles"].write_bytes(b"pmtiles")
|
||||
paths["manifest"].write_text('{"tileProvider":"pmtiles-mvt"}\n', encoding="utf-8")
|
||||
|
||||
status = earth_boundaries.get_boundary_status()
|
||||
|
||||
assert status["provider"] == "pmtiles-mvt"
|
||||
assert status["high_precision_ready"] is True
|
||||
|
||||
|
||||
def test_save_boundary_config_writes_local_config(monkeypatch, tmp_path):
|
||||
paths = patch_paths(monkeypatch, tmp_path)
|
||||
payload = {"collectorConfigs": {"earth_admin0_boundaries": {"endpoint": "file:///tmp/a.geojson"}}}
|
||||
|
||||
status = earth_boundaries.save_boundary_config(payload)
|
||||
|
||||
assert paths["config"].exists()
|
||||
assert status["config_source"] == "local"
|
||||
assert status["config"] == payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_reports_missing_tools_after_source_artifacts(monkeypatch, tmp_path):
|
||||
paths = patch_paths(monkeypatch, tmp_path)
|
||||
source_files = {}
|
||||
for source in earth_boundaries.BOUNDARY_SOURCE_KINDS:
|
||||
source_path = paths["repo"] / f"{source}.geojson"
|
||||
write_geojson(source_path, name=source)
|
||||
source_files[source] = source_path
|
||||
paths["config"].write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"collectorConfigs": {
|
||||
source: {
|
||||
"sourceKind": kind,
|
||||
"endpoint": str(source_files[source]),
|
||||
"method": "GET",
|
||||
}
|
||||
for source, kind in earth_boundaries.BOUNDARY_SOURCE_KINDS.items()
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(earth_boundaries.shutil, "which", lambda _tool: None)
|
||||
progress_events = []
|
||||
|
||||
status = await earth_boundaries.build_boundary_assets(
|
||||
lambda progress, phase, message, **_extra: progress_events.append((progress, phase, message))
|
||||
)
|
||||
|
||||
assert status["provider"] == "geojson-high-precision"
|
||||
assert status["high_precision_ready"] is True
|
||||
assert (paths["repo"] / "data/earth-boundary-sources/manifest.json").exists()
|
||||
assert paths["manifest"].exists()
|
||||
assert any(phase == "download" for _progress, phase, _message in progress_events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_boundary_build_job_records_geojson_fallback_success(monkeypatch, tmp_path):
|
||||
paths = patch_paths(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(earth_boundaries, "_build_task", None)
|
||||
monkeypatch.setattr(earth_boundaries, "_build_job_state", {})
|
||||
source_files = {}
|
||||
for source in earth_boundaries.BOUNDARY_SOURCE_KINDS:
|
||||
source_path = paths["repo"] / f"{source}.geojson"
|
||||
write_geojson(source_path, name=source)
|
||||
source_files[source] = source_path
|
||||
paths["config"].write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"collectorConfigs": {
|
||||
source: {
|
||||
"sourceKind": kind,
|
||||
"endpoint": str(source_files[source]),
|
||||
"method": "GET",
|
||||
}
|
||||
for source, kind in earth_boundaries.BOUNDARY_SOURCE_KINDS.items()
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(earth_boundaries.shutil, "which", lambda _tool: None)
|
||||
|
||||
response = await earth_boundaries.start_boundary_build_job()
|
||||
await earth_boundaries._build_task
|
||||
status = earth_boundaries.get_boundary_build_status()
|
||||
|
||||
assert response["accepted"] is True
|
||||
assert status["job"]["status"] == "succeeded"
|
||||
assert status["job"]["result"]["provider"] == "geojson-high-precision"
|
||||
|
||||
|
||||
def test_earth_boundary_collectors_are_not_registered_as_datasources():
|
||||
removed = set(earth_boundaries.BOUNDARY_SOURCE_KINDS) | {"earth_boundary_tiles"}
|
||||
|
||||
assert removed.isdisjoint(DEFAULT_DATASOURCES)
|
||||
for source in removed:
|
||||
assert collector_registry.get(source) is None
|
||||
@@ -1,6 +1,20 @@
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services.earth_news import ParsedNewsItem, _serialize_item
|
||||
import pytest
|
||||
|
||||
from app.services.earth_news import (
|
||||
NewsFeedSource,
|
||||
NewsTargetLocation,
|
||||
ParsedNewsItem,
|
||||
_enrich_items_with_target_locations,
|
||||
_extract_target_location_from_text,
|
||||
_serialize_item,
|
||||
get_earth_news_payload,
|
||||
)
|
||||
from app.services.earth_news_queue import NewsTargetLocationMessage
|
||||
from app.services.earth_news_worker import process_target_location_message
|
||||
from app.services.collectors.media_news_archive import MediaNewsArchiveCollector
|
||||
|
||||
|
||||
def test_serialize_item_includes_region_anchor_for_cruise():
|
||||
@@ -21,7 +35,10 @@ def test_serialize_item_includes_region_anchor_for_cruise():
|
||||
assert payload["latitude"] == 1.3521
|
||||
assert payload["longitude"] == 103.8198
|
||||
assert payload["location_label"] == "亚太"
|
||||
assert payload["location_inferred"] is True
|
||||
assert payload["location_source"] == "region_anchor"
|
||||
assert payload["verified"] is False
|
||||
assert payload["location_meta"]["target"] is None
|
||||
assert payload["location_meta"]["anchor"]["region"] == "asia-pacific"
|
||||
assert payload["is_focus_match"] is True
|
||||
assert payload["published_at"] == "2026-04-23T02:30:00Z"
|
||||
|
||||
@@ -44,6 +61,687 @@ def test_serialize_item_falls_back_to_global_anchor():
|
||||
assert payload["latitude"] == 20.0
|
||||
assert payload["longitude"] == 0.0
|
||||
assert payload["location_label"] == "全球"
|
||||
assert payload["location_inferred"] is True
|
||||
assert payload["location_source"] == "region_anchor"
|
||||
assert payload["verified"] is False
|
||||
assert payload["location_meta"]["anchor"]["region"] == "global"
|
||||
assert payload["is_focus_match"] is False
|
||||
assert payload["published_at"] is None
|
||||
|
||||
|
||||
def test_serialize_item_includes_inferred_target_location():
|
||||
item = ParsedNewsItem(
|
||||
id="bbc-world:f55310fb667b",
|
||||
title="Watch: What happened on day one of Trump's China visit?",
|
||||
summary=(
|
||||
"China welcomed US President Donald Trump with cheering children "
|
||||
"and a troop parade."
|
||||
),
|
||||
url="https://example.com/china-visit",
|
||||
source="BBC World",
|
||||
feed_name="BBC World",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 14, 7, 13, 24, tzinfo=UTC),
|
||||
target_location=NewsTargetLocation(
|
||||
latitude=39.9042,
|
||||
longitude=116.4074,
|
||||
label="Beijing, China",
|
||||
source="ai_inferred_target",
|
||||
confidence=0.88,
|
||||
country="中国",
|
||||
city="Beijing",
|
||||
),
|
||||
)
|
||||
|
||||
payload = _serialize_item(item, active_region="global")
|
||||
|
||||
assert payload["latitude"] == 39.9042
|
||||
assert payload["longitude"] == 116.4074
|
||||
assert payload["location_label"] == "Beijing, China"
|
||||
assert payload["location_source"] == "ai_inferred_target"
|
||||
assert payload["verified"] is True
|
||||
assert payload["location_meta"]["target"]["confidence"] == 0.88
|
||||
assert payload["location_meta"]["target"]["country"] == "中国"
|
||||
assert payload["location_meta"]["target"]["city"] == "Beijing"
|
||||
assert payload["location_meta"]["resolution_stage"] == "unresolved"
|
||||
assert payload["location_meta"]["ai_attempted"] is False
|
||||
assert payload["location_meta"]["ai_status"] == "not_attempted"
|
||||
assert payload["location_meta"]["ai_error"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_items_with_target_locations_uses_ai_and_geocode(monkeypatch):
|
||||
item = ParsedNewsItem(
|
||||
id="bbc-world:f55310fb667b",
|
||||
title="Watch: What happened on day one of Trump's China visit?",
|
||||
summary="China welcomed US President Donald Trump before a long meeting with Xi Jinping.",
|
||||
url="https://example.com/china-visit",
|
||||
source="BBC World",
|
||||
feed_name="BBC World",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 14, 7, 13, 24, tzinfo=UTC),
|
||||
)
|
||||
|
||||
async def fake_geocode(_query: str):
|
||||
return {
|
||||
"lat": "39.9042",
|
||||
"lon": "116.4074",
|
||||
"display_name": "Beijing, China",
|
||||
}
|
||||
|
||||
class FakeProviderClient:
|
||||
async def analyze(self, _request):
|
||||
class Response:
|
||||
content = (
|
||||
'{"country":"China","city":"Beijing","matched_location_name":"Beijing, China",'
|
||||
'"latitude":null,"longitude":null,"confidence":0.88}'
|
||||
)
|
||||
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news._geocode_target_location", fake_geocode)
|
||||
|
||||
enriched = await _enrich_items_with_target_locations(
|
||||
[item],
|
||||
provider_client=FakeProviderClient(),
|
||||
)
|
||||
|
||||
assert len(enriched) == 1
|
||||
assert enriched[0].target_location is not None
|
||||
assert enriched[0].target_location.latitude == 39.9042
|
||||
assert enriched[0].target_location.longitude == 116.4074
|
||||
assert enriched[0].target_location.label == "Beijing, China"
|
||||
assert enriched[0].target_resolution_stage == "ai_inferred_target"
|
||||
assert enriched[0].target_ai_attempted is True
|
||||
assert enriched[0].target_ai_status == "success"
|
||||
assert enriched[0].target_ai_error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_items_with_target_locations_adds_localizations(monkeypatch):
|
||||
item = ParsedNewsItem(
|
||||
id="global-scan:localized",
|
||||
title="Global leaders meet to discuss energy security",
|
||||
summary="Officials said the talks focused on supply chains and grid resilience.",
|
||||
url="https://example.com/energy-security",
|
||||
source="Example Source",
|
||||
feed_name="Global Monitor / World",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 14, 7, 13, 24, tzinfo=UTC),
|
||||
)
|
||||
|
||||
async def fake_geocode(_query: str):
|
||||
return {
|
||||
"lat": "50.1109",
|
||||
"lon": "8.6821",
|
||||
"display_name": "Frankfurt am Main, Germany",
|
||||
}
|
||||
|
||||
class FakeProviderClient:
|
||||
async def analyze(self, _request):
|
||||
class Response:
|
||||
content = (
|
||||
'{"location":{"country":"Germany","city":"Frankfurt",'
|
||||
'"matched_location_name":"Frankfurt, Germany",'
|
||||
'"latitude":null,"longitude":null,"confidence":0.77},'
|
||||
'"localizations":{"zh-CN":{"title":"全球领导人讨论能源安全",'
|
||||
'"summary":"官员表示,会谈聚焦供应链和电网韧性。"}}}'
|
||||
)
|
||||
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news._geocode_target_location", fake_geocode)
|
||||
|
||||
enriched = await _enrich_items_with_target_locations(
|
||||
[item],
|
||||
provider_client=FakeProviderClient(),
|
||||
)
|
||||
payload = _serialize_item(enriched[0], active_region="global")
|
||||
|
||||
assert payload["title"] == "Global leaders meet to discuss energy security"
|
||||
assert payload["summary"] == "Officials said the talks focused on supply chains and grid resilience."
|
||||
assert payload["localizations"]["zh-CN"]["title"] == "全球领导人讨论能源安全"
|
||||
assert payload["display_title"] == "全球领导人讨论能源安全"
|
||||
assert payload["display_summary"] == "官员表示,会谈聚焦供应链和电网韧性。"
|
||||
assert payload["enrichment_status"] == "success"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_target_location_from_text_uses_country_hint(monkeypatch):
|
||||
item = ParsedNewsItem(
|
||||
id="bbc-world:country-hint",
|
||||
title="Giant new dinosaur identified from fossils in Thailand",
|
||||
summary="The nagatitan is the largest dinosaur found in South-East Asia.",
|
||||
url="https://example.com/thailand-dinosaur",
|
||||
source="BBC World",
|
||||
feed_name="BBC World",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 14, 17, 28, 56, tzinfo=UTC),
|
||||
)
|
||||
|
||||
target = await _extract_target_location_from_text(item)
|
||||
|
||||
assert target is not None
|
||||
assert target.country == "泰国"
|
||||
assert target.latitude == 15.87
|
||||
assert target.longitude == 100.9925
|
||||
assert target.source == "headline_country_hint"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_items_with_target_locations_records_ai_provider_error():
|
||||
item = ParsedNewsItem(
|
||||
id="global-scan:no-hint",
|
||||
title="The New Geopolitics of Power: Whoever Controls Electrons Wins the Decade",
|
||||
summary="A broad analysis of industrial policy and energy systems.",
|
||||
url="https://example.com/geopolitics-power",
|
||||
source="Example Source",
|
||||
feed_name="Global Monitor / World",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 14, 17, 3, 10, tzinfo=UTC),
|
||||
)
|
||||
|
||||
class FailingProviderClient:
|
||||
async def analyze(self, _request):
|
||||
raise RuntimeError("upstream ai timeout")
|
||||
|
||||
enriched = await _enrich_items_with_target_locations(
|
||||
[item],
|
||||
provider_client=FailingProviderClient(),
|
||||
)
|
||||
|
||||
assert len(enriched) == 1
|
||||
assert enriched[0].target_location is None
|
||||
assert enriched[0].target_resolution_stage == "unresolved"
|
||||
assert enriched[0].target_ai_attempted is True
|
||||
assert enriched[0].target_ai_status == "provider_error"
|
||||
assert enriched[0].target_ai_error == "upstream ai timeout"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_earth_news_payload_returns_anchor_items_and_enqueues_location_job(monkeypatch):
|
||||
source = NewsFeedSource(
|
||||
id="test-feed",
|
||||
name="Test Feed",
|
||||
region="global",
|
||||
homepage_url="https://example.com",
|
||||
feed_url="https://example.com/rss.xml",
|
||||
)
|
||||
item = ParsedNewsItem(
|
||||
id="test-feed:timeout",
|
||||
title="Example story",
|
||||
summary="Example summary",
|
||||
url="https://example.com/story",
|
||||
source="Test Feed",
|
||||
feed_name="Test Feed",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
async def fake_fetch_source(_client, feed_source):
|
||||
return feed_source, [item], None
|
||||
|
||||
async def fake_get_cached_target_location_patch(_item_id):
|
||||
return None
|
||||
|
||||
enqueued_payloads = []
|
||||
|
||||
async def fake_enqueue_target_location_job(payload, **_kwargs):
|
||||
enqueued_payloads.append(payload)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source])
|
||||
monkeypatch.setattr("app.services.earth_news._fetch_source", fake_fetch_source)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_queue.get_cached_target_location_patch",
|
||||
fake_get_cached_target_location_patch,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_queue.enqueue_target_location_job",
|
||||
fake_enqueue_target_location_job,
|
||||
)
|
||||
|
||||
payload = await get_earth_news_payload(provider_client=None)
|
||||
|
||||
assert len(payload["items"]) == 1
|
||||
assert payload["items"][0]["id"] == "test-feed:timeout"
|
||||
assert payload["items"][0]["display_title"] == ""
|
||||
assert payload["items"][0]["display_summary"] == ""
|
||||
assert payload["items"][0]["latitude"] == 20.0
|
||||
assert payload["items"][0]["longitude"] == 0.0
|
||||
assert payload["items"][0]["location_source"] == "region_anchor"
|
||||
assert payload["items"][0]["verified"] is False
|
||||
assert payload["items"][0]["location_meta"]["ai_status"] == "queued"
|
||||
assert enqueued_payloads[0]["id"] == "test-feed:timeout"
|
||||
assert payload["errors"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_earth_news_payload_uses_fresh_database_items_without_rss(monkeypatch):
|
||||
db = object()
|
||||
item = ParsedNewsItem(
|
||||
id="db:fresh",
|
||||
title="Fresh database story",
|
||||
summary="Stored summary",
|
||||
url="https://example.com/fresh",
|
||||
source="Stored Source",
|
||||
feed_name="Stored Feed",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
)
|
||||
item.location_patch = {
|
||||
"latitude": 39.9057136,
|
||||
"longitude": 116.3912972,
|
||||
"location_label": "北京市, 中国",
|
||||
"location_source": "headline_location_hint",
|
||||
"verified": True,
|
||||
"location_meta": {"target": {"city": "Beijing"}, "anchor": {"region": "global"}},
|
||||
}
|
||||
|
||||
async def fake_get_earth_news_freshness(_db, *, active_region):
|
||||
return 12, datetime.now(UTC)
|
||||
|
||||
async def fake_list_earth_news_items(_db, *, active_region, limit):
|
||||
assert limit == 12
|
||||
return [item]
|
||||
|
||||
async def fail_fetch(_sources):
|
||||
raise AssertionError("fresh database items should not fetch RSS")
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness)
|
||||
monkeypatch.setattr("app.services.earth_news_store.list_earth_news_items", fake_list_earth_news_items)
|
||||
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fail_fetch)
|
||||
|
||||
payload = await get_earth_news_payload(db=db)
|
||||
|
||||
assert payload["items"][0]["id"] == "db:fresh"
|
||||
assert payload["items"][0]["verified"] is True
|
||||
assert payload["items"][0]["latitude"] == 39.9057136
|
||||
assert payload["stale"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_earth_news_payload_initializes_empty_database_from_rss(monkeypatch):
|
||||
db = object()
|
||||
source = NewsFeedSource(
|
||||
id="test-feed",
|
||||
name="Test Feed",
|
||||
region="global",
|
||||
homepage_url="https://example.com",
|
||||
feed_url="https://example.com/rss.xml",
|
||||
)
|
||||
item = ParsedNewsItem(
|
||||
id="test-feed:init",
|
||||
title="Initial RSS story",
|
||||
summary="Initial summary",
|
||||
url="https://example.com/init",
|
||||
source="Test Feed",
|
||||
feed_name="Test Feed",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
)
|
||||
item.location_patch = {
|
||||
"latitude": 20.0,
|
||||
"longitude": 0.0,
|
||||
"location_label": "全球",
|
||||
"location_source": "region_anchor",
|
||||
"verified": False,
|
||||
"location_meta": {"target": None, "anchor": {"region": "global"}},
|
||||
}
|
||||
upserted = []
|
||||
enqueued = []
|
||||
|
||||
async def fake_get_earth_news_freshness(_db, *, active_region):
|
||||
return 0, None
|
||||
|
||||
async def fake_fetch_rss_items_for_sources(_sources):
|
||||
return [item], []
|
||||
|
||||
async def fake_upsert_earth_news_items(_db, items):
|
||||
upserted.extend(items)
|
||||
return len(items)
|
||||
|
||||
async def fake_list_earth_news_items(_db, *, active_region, limit):
|
||||
return [item]
|
||||
|
||||
async def fake_enqueue_target_location_job(payload, **_kwargs):
|
||||
enqueued.append(payload)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source])
|
||||
monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness)
|
||||
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fake_fetch_rss_items_for_sources)
|
||||
monkeypatch.setattr("app.services.earth_news_store.upsert_earth_news_items", fake_upsert_earth_news_items)
|
||||
monkeypatch.setattr("app.services.earth_news_store.list_earth_news_items", fake_list_earth_news_items)
|
||||
monkeypatch.setattr("app.services.earth_news_queue.enqueue_target_location_job", fake_enqueue_target_location_job)
|
||||
|
||||
payload = await get_earth_news_payload(db=db)
|
||||
|
||||
assert upserted[0].id == "test-feed:init"
|
||||
assert payload["items"][0]["id"] == "test-feed:init"
|
||||
assert payload["items"][0]["verified"] is False
|
||||
assert enqueued[0]["id"] == "test-feed:init"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_earth_news_payload_supplements_stale_database_items(monkeypatch):
|
||||
db = object()
|
||||
old_item = ParsedNewsItem(
|
||||
id="db:old",
|
||||
title="Old story",
|
||||
summary="Old summary",
|
||||
url="https://example.com/old",
|
||||
source="Stored Source",
|
||||
feed_name="Stored Feed",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 14, 3, 0, tzinfo=UTC),
|
||||
)
|
||||
old_item.location_patch = {
|
||||
"latitude": 20.0,
|
||||
"longitude": 0.0,
|
||||
"location_label": "全球",
|
||||
"location_source": "region_anchor",
|
||||
"verified": False,
|
||||
"location_meta": {"target": None, "anchor": {"region": "global"}},
|
||||
}
|
||||
fetched = []
|
||||
|
||||
async def fake_get_earth_news_freshness(_db, *, active_region):
|
||||
return 12, datetime(2026, 5, 14, 3, 0, tzinfo=UTC)
|
||||
|
||||
async def fake_fetch_rss_items_for_sources(_sources):
|
||||
fetched.append(True)
|
||||
return [old_item], []
|
||||
|
||||
async def fake_upsert_earth_news_items(_db, items):
|
||||
return len(items)
|
||||
|
||||
async def fake_list_earth_news_items(_db, *, active_region, limit):
|
||||
return [old_item]
|
||||
|
||||
async def fake_enqueue_target_location_job(_payload, **_kwargs):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness)
|
||||
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fake_fetch_rss_items_for_sources)
|
||||
monkeypatch.setattr("app.services.earth_news_store.upsert_earth_news_items", fake_upsert_earth_news_items)
|
||||
monkeypatch.setattr("app.services.earth_news_store.list_earth_news_items", fake_list_earth_news_items)
|
||||
monkeypatch.setattr("app.services.earth_news_queue.enqueue_target_location_job", fake_enqueue_target_location_job)
|
||||
|
||||
payload = await get_earth_news_payload(db=db)
|
||||
|
||||
assert fetched == [True]
|
||||
assert payload["items"][0]["id"] == "db:old"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_earth_news_payload_merges_cached_location_patch(monkeypatch):
|
||||
source = NewsFeedSource(
|
||||
id="test-feed",
|
||||
name="Test Feed",
|
||||
region="global",
|
||||
homepage_url="https://example.com",
|
||||
feed_url="https://example.com/rss.xml",
|
||||
)
|
||||
item = ParsedNewsItem(
|
||||
id="test-feed:cached",
|
||||
title="Cached story",
|
||||
summary="Cached summary",
|
||||
url="https://example.com/cached",
|
||||
source="Test Feed",
|
||||
feed_name="Test Feed",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
)
|
||||
cached_patch = {
|
||||
"latitude": 39.9057136,
|
||||
"longitude": 116.3912972,
|
||||
"location_label": "北京市, 中国",
|
||||
"location_source": "headline_location_hint",
|
||||
"verified": True,
|
||||
"location_meta": {
|
||||
"resolution_stage": "headline_location_hint",
|
||||
"ai_attempted": False,
|
||||
"ai_status": "skipped_text_hint",
|
||||
"ai_error": None,
|
||||
"debug_note": "text hint matched 北京市, 中国",
|
||||
"target": {"city": "Beijing"},
|
||||
"anchor": {"region": "global"},
|
||||
},
|
||||
}
|
||||
|
||||
async def fake_fetch_source(_client, feed_source):
|
||||
return feed_source, [item], None
|
||||
|
||||
async def fake_get_cached_target_location_patch(_item_id):
|
||||
return cached_patch
|
||||
|
||||
enqueued = []
|
||||
|
||||
async def fake_enqueue_target_location_job(payload, **_kwargs):
|
||||
enqueued.append(payload)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source])
|
||||
monkeypatch.setattr("app.services.earth_news._fetch_source", fake_fetch_source)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_queue.get_cached_target_location_patch",
|
||||
fake_get_cached_target_location_patch,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_queue.enqueue_target_location_job",
|
||||
fake_enqueue_target_location_job,
|
||||
)
|
||||
|
||||
payload = await get_earth_news_payload(provider_client=None)
|
||||
|
||||
assert payload["items"][0]["latitude"] == 39.9057136
|
||||
assert payload["items"][0]["longitude"] == 116.3912972
|
||||
assert payload["items"][0]["verified"] is True
|
||||
assert payload["items"][0]["location_source"] == "headline_location_hint"
|
||||
assert enqueued[0]["id"] == "test-feed:cached"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_earth_news_payload_requeues_cached_failed_localization(monkeypatch):
|
||||
source = NewsFeedSource(
|
||||
id="test-feed",
|
||||
name="Test Feed",
|
||||
region="global",
|
||||
homepage_url="https://example.com",
|
||||
feed_url="https://example.com/rss.xml",
|
||||
)
|
||||
item = ParsedNewsItem(
|
||||
id="test-feed:failed-localization",
|
||||
title="Failed localization story",
|
||||
summary="English source summary.",
|
||||
url="https://example.com/failed-localization",
|
||||
source="Test Feed",
|
||||
feed_name="Test Feed",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
)
|
||||
cached_patch = {
|
||||
"latitude": 20.0,
|
||||
"longitude": 0.0,
|
||||
"location_label": "全球",
|
||||
"location_source": "region_anchor",
|
||||
"verified": False,
|
||||
"location_meta": {"target": None, "anchor": {"region": "global"}},
|
||||
"content_language": "en",
|
||||
"localizations": {},
|
||||
"enrichment_status": "parse_error",
|
||||
"enrichment_error": "AI response did not contain a parseable JSON object.",
|
||||
"enriched_at": None,
|
||||
}
|
||||
enqueued = []
|
||||
|
||||
async def fake_fetch_source(_client, feed_source):
|
||||
return feed_source, [item], None
|
||||
|
||||
async def fake_get_cached_target_location_patch(_item_id):
|
||||
return cached_patch
|
||||
|
||||
async def fake_enqueue_target_location_job(payload, **_kwargs):
|
||||
enqueued.append(payload)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source])
|
||||
monkeypatch.setattr("app.services.earth_news._fetch_source", fake_fetch_source)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_queue.get_cached_target_location_patch",
|
||||
fake_get_cached_target_location_patch,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_queue.enqueue_target_location_job",
|
||||
fake_enqueue_target_location_job,
|
||||
)
|
||||
|
||||
payload = await get_earth_news_payload(provider_client=None)
|
||||
|
||||
assert enqueued[0]["id"] == "test-feed:failed-localization"
|
||||
assert payload["items"][0]["display_title"] == ""
|
||||
assert payload["items"][0]["enrichment_status"] == "queued"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_processes_target_location_message_and_returns_patch(monkeypatch):
|
||||
message = NewsTargetLocationMessage(
|
||||
message_id="1-0",
|
||||
item_id="bbc-world:worker",
|
||||
payload={
|
||||
"id": "bbc-world:worker",
|
||||
"title": "Ukraine rescuers pull dead from rubble of Kyiv flats",
|
||||
"summary": "Massive Russian drone and missile attacks in Ukraine's capital.",
|
||||
"url": "https://example.com/kyiv",
|
||||
"source": "BBC World",
|
||||
"feed_name": "BBC World",
|
||||
"feed_region": "global",
|
||||
"homepage_url": "https://www.bbc.com/news/world",
|
||||
"published_at": "2026-05-14T13:16:32Z",
|
||||
},
|
||||
)
|
||||
|
||||
async def fake_geocode(_query: str):
|
||||
return {
|
||||
"lat": "50.4500336",
|
||||
"lon": "30.5241361",
|
||||
"display_name": "Київ, Україна",
|
||||
}
|
||||
|
||||
saved = {}
|
||||
broadcasted = {}
|
||||
|
||||
async def fake_save_target_location_patch(item_id, patch):
|
||||
saved["item_id"] = item_id
|
||||
saved["patch"] = patch
|
||||
|
||||
async def fake_update_earth_news_item_location(_session, *, item_id, patch):
|
||||
saved["db_item_id"] = item_id
|
||||
saved["db_patch"] = patch
|
||||
return True
|
||||
|
||||
async def fake_broadcast_custom(channel, data):
|
||||
broadcasted["channel"] = channel
|
||||
broadcasted["data"] = data
|
||||
|
||||
class FakeSession:
|
||||
async def commit(self):
|
||||
saved["committed"] = True
|
||||
|
||||
class FakeSessionFactory:
|
||||
async def __aenter__(self):
|
||||
return FakeSession()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news._geocode_target_location", fake_geocode)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_worker.save_target_location_patch",
|
||||
fake_save_target_location_patch,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_worker.update_earth_news_item_location",
|
||||
fake_update_earth_news_item_location,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_worker.async_session_factory",
|
||||
lambda: FakeSessionFactory(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_worker.broadcaster.broadcast_custom",
|
||||
fake_broadcast_custom,
|
||||
)
|
||||
|
||||
patch = await process_target_location_message(message, provider_client=None)
|
||||
|
||||
assert patch["latitude"] == 50.4500336
|
||||
assert patch["longitude"] == 30.5241361
|
||||
assert patch["location_source"] == "headline_location_hint"
|
||||
assert patch["verified"] is True
|
||||
assert saved["item_id"] == "bbc-world:worker"
|
||||
assert saved["db_item_id"] == "bbc-world:worker"
|
||||
assert saved["committed"] is True
|
||||
assert broadcasted["channel"] == "earth_news"
|
||||
assert broadcasted["data"]["item_id"] == "bbc-world:worker"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_news_archive_collector_maps_news_items(monkeypatch):
|
||||
collector = MediaNewsArchiveCollector()
|
||||
collector._db_session = object()
|
||||
record = SimpleNamespace(
|
||||
id="bbc-world:archive",
|
||||
title="Archived news",
|
||||
summary="Archived summary",
|
||||
url="https://example.com/archive",
|
||||
source="BBC World",
|
||||
feed_name="BBC World",
|
||||
region="global",
|
||||
homepage_url="https://www.bbc.com/news/world",
|
||||
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
latitude=39.9057136,
|
||||
longitude=116.3912972,
|
||||
location_label="北京市, 中国",
|
||||
location_source="headline_location_hint",
|
||||
verified=True,
|
||||
location_meta={"target": {"country": "中国", "city": "Beijing"}},
|
||||
content_language="en",
|
||||
localizations={"zh-CN": {"title": "归档新闻", "summary": "归档概要"}},
|
||||
enrichment_status="success",
|
||||
enrichment_error=None,
|
||||
enriched_at=datetime(2026, 5, 15, 3, 6, tzinfo=UTC),
|
||||
first_seen_at=datetime(2026, 5, 15, 2, 0, tzinfo=UTC),
|
||||
last_seen_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
resolved_at=datetime(2026, 5, 15, 3, 5, tzinfo=UTC),
|
||||
)
|
||||
|
||||
async def fake_list_all_earth_news_records(_db):
|
||||
return [record]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.collectors.media_news_archive.list_all_earth_news_records",
|
||||
fake_list_all_earth_news_records,
|
||||
)
|
||||
|
||||
items = await collector.fetch()
|
||||
|
||||
assert items[0]["source_id"] == "bbc-world:archive"
|
||||
assert collector.data_type == "news_item"
|
||||
assert items[0]["country"] == "中国"
|
||||
assert items[0]["city"] == "Beijing"
|
||||
assert items[0]["latitude"] == 39.9057136
|
||||
assert items[0]["metadata"]["verified"] is True
|
||||
assert "localizations" not in items[0]["metadata"]
|
||||
assert "enrichment_status" not in items[0]["metadata"]
|
||||
|
||||
92
backend/tests/test_settings_ai_prompts.py
Normal file
92
backend/tests/test_settings_ai_prompts.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai_tasks.prompts import (
|
||||
get_effective_prompt,
|
||||
list_effective_prompts,
|
||||
reset_prompt_override,
|
||||
save_prompt_override,
|
||||
)
|
||||
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class _PromptSettingsDB:
|
||||
def __init__(self, payload=None):
|
||||
self.record = SimpleNamespace(category="ai_prompts", payload=payload) if payload is not None else None
|
||||
self.added = None
|
||||
self.commits = 0
|
||||
|
||||
async def execute(self, _statement):
|
||||
return _ScalarResult(self.record)
|
||||
|
||||
def add(self, record):
|
||||
self.record = record
|
||||
self.added = record
|
||||
|
||||
async def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_defaults_are_loaded_without_override():
|
||||
db = _PromptSettingsDB()
|
||||
|
||||
prompt = await get_effective_prompt(db, "earth.news.enrich")
|
||||
|
||||
assert prompt.key == "earth.news.enrich"
|
||||
assert prompt.is_custom is False
|
||||
assert "strict JSON" in prompt.prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_override_save_and_reset():
|
||||
db = _PromptSettingsDB()
|
||||
|
||||
saved = await save_prompt_override(
|
||||
db,
|
||||
"alerts.brief",
|
||||
system_prompt="system custom",
|
||||
prompt="prompt custom",
|
||||
)
|
||||
|
||||
assert saved.is_custom is True
|
||||
assert saved.system_prompt == "system custom"
|
||||
assert saved.prompt == "prompt custom"
|
||||
assert db.commits == 1
|
||||
|
||||
effective = await get_effective_prompt(db, "alerts.brief")
|
||||
assert effective.prompt == "prompt custom"
|
||||
|
||||
reset = await reset_prompt_override(db, "alerts.brief")
|
||||
assert reset.is_custom is False
|
||||
assert reset.prompt != "prompt custom"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_list_marks_custom_items():
|
||||
db = _PromptSettingsDB(
|
||||
{
|
||||
"overrides": {
|
||||
"bgp.brief": {
|
||||
"system_prompt": "",
|
||||
"prompt": "custom bgp prompt",
|
||||
"updated_at": "2026-05-16T00:00:00Z",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
prompts = await list_effective_prompts(db)
|
||||
by_key = {prompt.key: prompt for prompt in prompts}
|
||||
|
||||
assert by_key["bgp.brief"].is_custom is True
|
||||
assert by_key["bgp.brief"].prompt == "custom bgp prompt"
|
||||
assert by_key["earth.news.enrich"].is_custom is False
|
||||
12
backend/tests/test_settings_secret_placeholder.py
Normal file
12
backend/tests/test_settings_secret_placeholder.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from app.api.v1.settings import _is_secret_placeholder
|
||||
|
||||
|
||||
def test_secret_placeholder_treats_masked_values_as_placeholder():
|
||||
assert _is_secret_placeholder("••••••••", "••••1234") is True
|
||||
assert _is_secret_placeholder("********", "") is True
|
||||
assert _is_secret_placeholder(" * * * ", "") is True
|
||||
|
||||
|
||||
def test_secret_placeholder_preserves_real_keys_with_asterisks():
|
||||
assert _is_secret_placeholder("sk-live-*real-key*", "") is False
|
||||
assert _is_secret_placeholder("token_with*embedded*star", "") is False
|
||||
71
backend/tests/test_situational_alert_ai_brief.py
Normal file
71
backend/tests/test_situational_alert_ai_brief.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import pytest
|
||||
|
||||
from app.services.situational_alert_ai_brief import build_situational_alert_brief_request
|
||||
|
||||
|
||||
class _SingleUseScalarResult:
|
||||
def __init__(self, value=0, rows=None):
|
||||
self.value = value
|
||||
self.rows = rows or []
|
||||
self.scalar_calls = 0
|
||||
|
||||
def scalar(self):
|
||||
self.scalar_calls += 1
|
||||
if self.scalar_calls > 1:
|
||||
raise AssertionError("scalar result was consumed more than once")
|
||||
return self.value
|
||||
|
||||
def fetchall(self):
|
||||
return self.rows
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return None
|
||||
|
||||
def scalars(self):
|
||||
rows = self.rows
|
||||
|
||||
class _Scalars:
|
||||
def all(self):
|
||||
return rows
|
||||
|
||||
return _Scalars()
|
||||
|
||||
|
||||
class _FakeBriefSession:
|
||||
def __init__(self):
|
||||
self._results = [
|
||||
_SingleUseScalarResult(3),
|
||||
_SingleUseScalarResult(2),
|
||||
_SingleUseScalarResult(rows=[]),
|
||||
_SingleUseScalarResult(rows=[]),
|
||||
_SingleUseScalarResult(rows=[]),
|
||||
_SingleUseScalarResult(4),
|
||||
_SingleUseScalarResult(1),
|
||||
_SingleUseScalarResult(rows=[]),
|
||||
_SingleUseScalarResult(rows=[]),
|
||||
_SingleUseScalarResult(5),
|
||||
_SingleUseScalarResult(2),
|
||||
_SingleUseScalarResult(rows=[]),
|
||||
_SingleUseScalarResult(),
|
||||
]
|
||||
|
||||
async def execute(self, _query):
|
||||
return self._results.pop(0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_situational_alert_brief_builder_reuses_counts_without_reconsuming_results(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"app.services.situational_alert_ai_brief.get_latest_bgp_brief_record",
|
||||
lambda: None,
|
||||
)
|
||||
|
||||
request, facts, context = await build_situational_alert_brief_request(_FakeBriefSession())
|
||||
|
||||
assert request.title == "态势告警 AI 简报"
|
||||
assert "总告警 3 条,active 2 条" in facts[0]
|
||||
assert "累计 incidents 4 条,active incidents 1 条" in facts[1]
|
||||
assert "累计 anomalies 5 条,active anomalies 2 条" in facts[2]
|
||||
assert context["active_system_alerts"] == 2
|
||||
assert context["active_bgp_incidents"] == 1
|
||||
assert context["active_bgp_anomalies"] == 2
|
||||
92
config/earth-boundary-pov-policy.china-v1.json
Normal file
92
config/earth-boundary-pov-policy.china-v1.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"schema": "planet-earth-boundary-pov-policy/v1",
|
||||
"profile": "china-pov-v1",
|
||||
"description": "Product boundary policy for the China POV Earth boundary build. This file declares intent only; geometry must come from audited source packages and be applied offline before PMTiles/MVT generation.",
|
||||
"defaultCountryHandling": "source-admin0-with-reviewed-overrides",
|
||||
"rules": [
|
||||
{
|
||||
"id": "china-zangnan",
|
||||
"name": "Zangnan / South Tibet",
|
||||
"action": "union_to_country",
|
||||
"targetIsoA3": "CHN",
|
||||
"subtractFromIsoA3": ["IND"],
|
||||
"hoverIsoA3": "CHN",
|
||||
"labelPolicy": "show_country_only"
|
||||
},
|
||||
{
|
||||
"id": "china-aksai-chin",
|
||||
"name": "Aksai Chin",
|
||||
"action": "union_to_country",
|
||||
"targetIsoA3": "CHN",
|
||||
"subtractFromIsoA3": ["IND"],
|
||||
"hoverIsoA3": "CHN",
|
||||
"labelPolicy": "show_country_only"
|
||||
},
|
||||
{
|
||||
"id": "china-taiwan-penghu",
|
||||
"name": "Taiwan and Penghu",
|
||||
"action": "union_to_country",
|
||||
"targetIsoA3": "CHN",
|
||||
"hoverIsoA3": "CHN",
|
||||
"labelPolicy": "show_country_only"
|
||||
},
|
||||
{
|
||||
"id": "china-diaoyu-chiwei",
|
||||
"name": "Diaoyu Dao, affiliated islands, and Chiwei Yu",
|
||||
"action": "union_to_country",
|
||||
"targetIsoA3": "CHN",
|
||||
"hoverIsoA3": "CHN",
|
||||
"labelPolicy": "show_country_only"
|
||||
},
|
||||
{
|
||||
"id": "china-south-china-sea-islands",
|
||||
"name": "Dongsha, Xisha, Zhongsha, Nansha, Huangyan Dao, Zengmu Ansha and related islands/reefs",
|
||||
"action": "union_to_country",
|
||||
"targetIsoA3": "CHN",
|
||||
"hoverIsoA3": "CHN",
|
||||
"labelPolicy": "show_country_only"
|
||||
},
|
||||
{
|
||||
"id": "china-maritime-claim-line",
|
||||
"name": "South China Sea dashed maritime claim line",
|
||||
"action": "render_claim_line",
|
||||
"targetIsoA3": "CHN",
|
||||
"geometryRole": "claim_line_only",
|
||||
"landPolygonEffect": "none"
|
||||
},
|
||||
{
|
||||
"id": "kosovo",
|
||||
"name": "Kosovo",
|
||||
"action": "render_as_disputed_with_parent",
|
||||
"parentIsoA3": "SRB",
|
||||
"hoverIsoA3": "SRB",
|
||||
"boundaryStyle": "disputed_internal",
|
||||
"labelPolicy": "show_parent_country"
|
||||
},
|
||||
{
|
||||
"id": "gaza",
|
||||
"name": "Gaza Strip",
|
||||
"action": "render_as_region_of_country",
|
||||
"targetIsoA3": "PSE",
|
||||
"hoverIsoA3": "PSE",
|
||||
"boundaryStyle": "admin_or_disputed",
|
||||
"labelPolicy": "show_country_only"
|
||||
}
|
||||
],
|
||||
"sourceRequirements": {
|
||||
"geometryMustBeAudited": true,
|
||||
"noHandDrawnClaimLines": true,
|
||||
"noFrontendRuntimePovPatch": true,
|
||||
"artifactIsolation": "one PMTiles/MVT artifact per POV profile"
|
||||
},
|
||||
"officialPositionNotes": [
|
||||
{
|
||||
"id": "kosovo",
|
||||
"note": "China has emphasized respect for Serbia's sovereignty and territorial integrity and the framework of UNSC Resolution 1244."
|
||||
},
|
||||
{
|
||||
"id": "gaza",
|
||||
"note": "China supports the two-state solution and an independent State of Palestine based on the 1967 borders with East Jerusalem as its capital; Gaza governance should follow Palestinians governing Palestine."
|
||||
}
|
||||
]
|
||||
}
|
||||
112
config/earth-boundary-sources.example.json
Normal file
112
config/earth-boundary-sources.example.json
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"policy": {
|
||||
"runtimeFetch": false,
|
||||
"profile": "china-pov-v1",
|
||||
"povPolicyPath": "config/earth-boundary-pov-policy.china-v1.json",
|
||||
"productionTileFormat": "pmtiles+mvt",
|
||||
"debugTileFormat": "geojson-directory",
|
||||
"description": "Default Earth boundary update sources. These public Natural Earth endpoints make local high-precision boundary download work out of the box; replace with audited internal sources for production if needed."
|
||||
},
|
||||
"collectorConfigs": {
|
||||
"earth_admin0_boundaries": {
|
||||
"displayName": "Earth Admin-0 国界源",
|
||||
"sourceKind": "admin0-boundaries",
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_countries.geojson",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"auth_type": "none",
|
||||
"license": "Natural Earth public domain",
|
||||
"mapping_json": {
|
||||
"source": {
|
||||
"items_path": "$.features[*]"
|
||||
},
|
||||
"fields": {
|
||||
"source_id": {
|
||||
"path": "$.properties.id",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"path": "$.properties.name",
|
||||
"type": "string"
|
||||
},
|
||||
"geometry": {
|
||||
"path": "$.geometry",
|
||||
"type": "object"
|
||||
},
|
||||
"properties": {
|
||||
"path": "$.properties",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"earth_coastline": {
|
||||
"displayName": "Earth 海岸线源",
|
||||
"sourceKind": "coastline",
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_coastline.geojson",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"auth_type": "none",
|
||||
"license": "Natural Earth public domain",
|
||||
"mapping_json": {
|
||||
"source": {
|
||||
"items_path": "$.features[*]"
|
||||
},
|
||||
"fields": {
|
||||
"source_id": {
|
||||
"path": "$.properties.id",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"path": "$.properties.name",
|
||||
"type": "string"
|
||||
},
|
||||
"geometry": {
|
||||
"path": "$.geometry",
|
||||
"type": "object"
|
||||
},
|
||||
"properties": {
|
||||
"path": "$.properties",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"earth_claim_lines": {
|
||||
"displayName": "Earth 主张线源",
|
||||
"sourceKind": "claim-lines",
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_boundary_lines_disputed_areas.geojson",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"auth_type": "none",
|
||||
"license": "Natural Earth public domain",
|
||||
"mapping_json": {
|
||||
"source": {
|
||||
"items_path": "$.features[*]"
|
||||
},
|
||||
"fields": {
|
||||
"source_id": {
|
||||
"path": "$.properties.id",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"path": "$.properties.name",
|
||||
"type": "string"
|
||||
},
|
||||
"geometry": {
|
||||
"path": "$.geometry",
|
||||
"type": "object"
|
||||
},
|
||||
"properties": {
|
||||
"path": "$.properties",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notes": [
|
||||
"Earth can download these sources directly from the toolbar settings when no local source override exists.",
|
||||
"If tippecanoe/pmtiles are unavailable, the backend generates a GeoJSON high-precision package so the feature remains usable."
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,73 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.60.0] — 2026-05-17
|
||||
|
||||
Released: 2026-05-17
|
||||
|
||||
### Highlights
|
||||
- 新增 Earth 内容与国界运行体验:品牌内容配置、国界精度滑块、高精国界首次应用按钮和高精重载构建入口共同收口。
|
||||
- 完成新闻中文展示链路收敛:滚动新闻改用一句话摘要,新闻本地化状态避免未完成内容进入滚动展示,英文原文继续保留在数据层。
|
||||
- 重构 AI Provider 与提示词边界:`aiprovider` 保持纯净模型适配,业务提示词集中到后端 AI task 默认注册表并支持运维台覆盖和重置。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 新增 AI 设置提示词 tab,按功能入口选择、编辑、保存和重置默认提示词,并修复态势告警 / BGP 简报展示生成内容而非 prompt。
|
||||
- 更新 Earth 新闻、HUD、巡航、桌面详情和移动详情的中文显示策略,并优化新闻摘要提示词为新闻式一句话概要。
|
||||
- 补充 Agent Runtime、Earth LLM 指令、语音识别和多角色态势感知的详细计划,并同步 README 架构说明、用户手册、FAQ 与开发者文档。
|
||||
- 补齐相关后端测试,覆盖 Earth 品牌配置、AI 简报接口和态势告警 AI 简报生成链路。
|
||||
|
||||
---
|
||||
|
||||
## [0.59.0] — 2026-05-16
|
||||
|
||||
Released: 2026-05-16
|
||||
|
||||
### Highlights
|
||||
- 将 Earth 国界从采集器体系迁移为 Earth 静态资产,恢复低精 GeoJSON fallback,并新增 Earth 工具栏高精国界下载/构建进度与热应用。
|
||||
- 重组后台“运维与配置”:新增 Earth 内容与采集管理二级入口,电视直播、国界精度、采集器、采集调度各归其位,未接入模块以占位页呈现。
|
||||
- 新增 AI task prompt 覆盖管理,按稳定 task key 管理新闻汉化、告警研判、BGP 简报等业务提示词,避免全局 prompt 污染。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- Earth 新闻锚点链路增加队列化 enrichment 状态、Redis Streams 后台精修和 WebSocket patch 语义,前端汉化/锚点策略更稳定。
|
||||
- 国界 hover 与 interactable tooltip 解耦,鼠标位于国家 polygon 内时保持国界高亮,同时卫星/船只/BGP 等对象仍可显示自身信息。
|
||||
- 新增 `/api/v1/earth/boundaries/*` 状态、配置、构建和进度接口,并在启动初始化中清理旧 boundary datasource/task/snapshot 历史入口。
|
||||
- 补齐中英文用户手册、FAQ、quickstart、运维手册和开发者上下文文档,明确用户 UI、运维操作和开发者稳定边界。
|
||||
|
||||
---
|
||||
|
||||
## [0.58.0] — 2026-05-15
|
||||
|
||||
Released: 2026-05-15
|
||||
|
||||
### Highlights
|
||||
- 新增 Earth 高精度国界 PMTiles/MVT 前端链路,移除旧低精度 GeoJSON 国界兜底,国界缺失时显式报错。
|
||||
- 将 Earth 边界数据拆成 Admin-0、coastline、claim-lines 三个标准源采集器,并把 `earth_boundary_tiles` 收口为下游 PMTiles 构建器。
|
||||
- 修复 Earth 远距缩放下海陆基座与高清贴图 z-fighting 导致的雪花/黑块闪烁,并记录地表多层 shell 的深度间距规则。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 新增 `earth_boundary_source` 目标 schema、China POV policy 配置、PMTiles readiness/build 脚本和 collector artifact 登记流程。
|
||||
- Earth 新闻巡航改为优先使用可缓存的目标地点解析队列,并补充媒体新闻归档采集器与回归测试。
|
||||
- 调整数据源列表任务状态展示,让 Earth 采集器失败/未就绪状态可见,不再表现为“未执行”。
|
||||
- 更新中英文采集器、数据源设置、Earth 图层顺序、运维 runbook、FAQ、规则和计划文档。
|
||||
|
||||
---
|
||||
|
||||
## [0.57.0] — 2026-05-14
|
||||
|
||||
Released: 2026-05-14
|
||||
|
||||
### Highlights
|
||||
- 新增 WSL `--allow-lan` 临时 Windows relay,保持本机 `localhost:3000` / `localhost:8000` 不变,同时用 Windows 局域网 IP 暴露相同端口。
|
||||
- 启动脚本会检测旧 `netsh interface portproxy` 冲突并请求管理员 PowerShell 清理,避免 `svchost.exe / iphlpsvc` 持久占用 3000/8000。
|
||||
- 修复 Vite CJS Node API deprecated warning,将前端配置迁移到 ESM,并让脚本按实际后端端口注入代理目标。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 新增 [scripts/windows-lan-relay.ps1](/home/ray/dev/linkong/planet/scripts/windows-lan-relay.ps1),在 Windows 侧启动随 WSL 服务健康状态自动退出的 TCP relay。
|
||||
- `planet.sh --allow-lan` 仅在 WSL + PowerShell 可用时启用 Windows relay,并自动检查 Windows 防火墙规则;非 WSL 环境保持原有路径。
|
||||
- 更新中英文 README、FAQ 和运维文档,说明旧 portproxy 清理、UAC 防火墙授权、同端口 localhost/LAN 访问和故障恢复方式。
|
||||
|
||||
---
|
||||
|
||||
## [0.56.0] — 2026-05-13
|
||||
|
||||
Released: 2026-05-13
|
||||
|
||||
810
docs/plans/agents-earth-command-runtime-plan.md
Normal file
810
docs/plans/agents-earth-command-runtime-plan.md
Normal file
@@ -0,0 +1,810 @@
|
||||
# Agent Runtime, Earth LLM Command, And Speech Entry Plan
|
||||
|
||||
## Summary
|
||||
|
||||
Build an auditable backend Agent Runtime and upgrade the existing Earth search panel into a combined search, AI command, and voice wake entry. The first version is a runtime foundation, not the full multi-role simulation product yet.
|
||||
|
||||
Typical user goals:
|
||||
|
||||
- In Earth, type "高亮所有北斗卫星" and have the system open the satellite layer and highlight matching Beidou satellites.
|
||||
- Type "中国大陆的算力中心" and have the system open the compute-center layer, match mainland China compute centers, and highlight them.
|
||||
- When microphone permission is granted, use a configurable wake word, then speak an Earth command.
|
||||
- Save every Earth AI command as an agent run so operators can review the original input, speech transcription, tool steps, entity matches, final action plan, and frontend execution result.
|
||||
|
||||
Core boundaries:
|
||||
|
||||
- `aiprovider` remains the model gateway. It must not own business tools, database access, Earth actions, or agent policy.
|
||||
- The backend owns agent orchestration, tools, evidence storage, permission policy, and proposal application.
|
||||
- Earth v1 executes visualization actions only. It does not mutate business data.
|
||||
- Speech recognition uses a provider-neutral ASR API first, defaulting to OpenAI/Whisper-compatible transcription APIs, with local `whisper.cpp` style providers as later adapters.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend Agent Runtime
|
||||
|
||||
Recommended module shape:
|
||||
|
||||
```text
|
||||
backend/app/models/
|
||||
agent_run.py
|
||||
agent_step.py
|
||||
agent_evidence.py
|
||||
agent_proposal.py
|
||||
|
||||
backend/app/schemas/
|
||||
agents.py
|
||||
speech.py
|
||||
|
||||
backend/app/services/agents/
|
||||
runtime.py
|
||||
orchestrator.py
|
||||
tool_protocol.py
|
||||
tool_registry.py
|
||||
policy.py
|
||||
proposals.py
|
||||
earth_command.py
|
||||
entity_query.py
|
||||
speech.py
|
||||
|
||||
backend/app/api/v1/
|
||||
agents.py
|
||||
```
|
||||
|
||||
Database conventions should follow the current project style:
|
||||
|
||||
- Use SQLAlchemy models.
|
||||
- Import new models from `init_db()`.
|
||||
- Let `Base.metadata.create_all` create tables.
|
||||
- Add required indexes with `CREATE INDEX IF NOT EXISTS`.
|
||||
- Do not introduce Alembic for this feature.
|
||||
|
||||
### aiprovider Boundary
|
||||
|
||||
`aiprovider` should continue to provide model transport only:
|
||||
|
||||
- Keep the existing `/v1/analyze` endpoint.
|
||||
- If schemas are extended, only pass through model/provider request fields and normalize responses.
|
||||
- Do not add WebSearch, database queries, Earth entity lookup, or business action execution inside `aiprovider`.
|
||||
- If a provider does not support native tool calling, the backend must use JSON tool-call fallback.
|
||||
|
||||
### Agent Tool Protocol
|
||||
|
||||
The first version should support two protocols:
|
||||
|
||||
- Default path: backend JSON tool-call loop.
|
||||
- Optional path: provider-native tools when the configured provider supports them.
|
||||
- Fallback path: if provider-native tools are unavailable or unstable, automatically use JSON tool-call.
|
||||
|
||||
Example JSON tool call:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "tool_call",
|
||||
"tool": "earth.find_entities",
|
||||
"arguments": {
|
||||
"domain": "satellites",
|
||||
"filters": {
|
||||
"constellation": "beidou"
|
||||
},
|
||||
"limit": 500
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example final response:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "final",
|
||||
"summary": "已找到并高亮北斗卫星。",
|
||||
"result": {
|
||||
"actions": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Hard limits:
|
||||
|
||||
- One run may execute at most 8 tool steps by default.
|
||||
- One tool call times out after 30 seconds by default.
|
||||
- Only registered whitelist tools may run.
|
||||
- Tool arguments must pass Pydantic validation.
|
||||
- Illegal tools, invalid arguments, and denied actions must be recorded as step errors.
|
||||
- LLM output may not directly write business state. Writes are either proposals or backend-executed policy-approved actions.
|
||||
|
||||
## Data Model
|
||||
|
||||
### AgentRun
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `id`: integer primary key.
|
||||
- `public_id`: public string id.
|
||||
- `run_type`: `earth_command`, `situational_awareness`, `config_proposal`, or `diagnostic`.
|
||||
- `status`: `queued`, `running`, `waiting_approval`, `completed`, `failed`, or `stopped`.
|
||||
- `title`: short display title.
|
||||
- `objective`: text objective.
|
||||
- `input`: JSONB original input, including text and audio metadata.
|
||||
- `context`: JSONB run context.
|
||||
- `result_markdown`: final human-readable output.
|
||||
- `result_json`: structured output, including Earth action plans.
|
||||
- `provider`: nullable provider id.
|
||||
- `model`: nullable model id.
|
||||
- `request_id`: nullable propagated request id.
|
||||
- `created_by`: user id.
|
||||
- `created_at`, `updated_at`, `completed_at`.
|
||||
- `error`: nullable text error.
|
||||
|
||||
### AgentStep
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `id`.
|
||||
- `run_id`.
|
||||
- `step_index`.
|
||||
- `step_type`: `llm`, `tool`, `policy`, `action`, or `transcription`.
|
||||
- `status`: `pending`, `running`, `completed`, `failed`, or `skipped`.
|
||||
- `name`: step name, for example `earth.find_entities`.
|
||||
- `input`: JSONB.
|
||||
- `output`: JSONB.
|
||||
- `error`: nullable text.
|
||||
- `started_at`, `completed_at`.
|
||||
- `duration_ms`.
|
||||
|
||||
### AgentEvidence
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `id`.
|
||||
- `run_id`.
|
||||
- `step_id`: nullable.
|
||||
- `evidence_type`: `internal_record`, `web_search`, `web_fetch`, `entity_match`, or `transcription`.
|
||||
- `source`: source id.
|
||||
- `title`: display title.
|
||||
- `url`: nullable source URL.
|
||||
- `content`: text evidence content.
|
||||
- `content_hash`: nullable hash.
|
||||
- `metadata`: JSONB.
|
||||
- `retrieved_at`.
|
||||
|
||||
### AgentProposal
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `id`.
|
||||
- `run_id`.
|
||||
- `proposal_type`: `datasource_config`, `ai_prompt`, or `external_integration`.
|
||||
- `status`: `pending`, `approved`, `applied`, `rejected`, or `failed`.
|
||||
- `risk_level`: `low`, `medium`, or `high`.
|
||||
- `target`: JSONB target descriptor.
|
||||
- `before_payload`: JSONB.
|
||||
- `after_payload`: JSONB.
|
||||
- `rationale`: text.
|
||||
- `policy_result`: JSONB.
|
||||
- `applied_by`: nullable user id.
|
||||
- `applied_at`: nullable timestamp.
|
||||
- `error`: nullable text.
|
||||
|
||||
## Public APIs
|
||||
|
||||
### Agent Runs
|
||||
|
||||
Add:
|
||||
|
||||
```text
|
||||
POST /api/v1/agents/runs
|
||||
GET /api/v1/agents/runs
|
||||
GET /api/v1/agents/runs/{run_id}
|
||||
POST /api/v1/agents/runs/{run_id}/stop
|
||||
POST /api/v1/agents/proposals/{proposal_id}/apply
|
||||
```
|
||||
|
||||
### Earth Command
|
||||
|
||||
Add:
|
||||
|
||||
```text
|
||||
POST /api/v1/agents/earth/command
|
||||
```
|
||||
|
||||
Request shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"input_text": "高亮所有北斗卫星",
|
||||
"source": "text",
|
||||
"transcription_id": null,
|
||||
"client_context": {
|
||||
"visible_layers": ["satellites"],
|
||||
"locale": "zh-CN",
|
||||
"viewport": {
|
||||
"is_mobile": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "agent_xxx",
|
||||
"summary": "已找到并高亮北斗卫星。",
|
||||
"actions": [
|
||||
{
|
||||
"type": "show_layer",
|
||||
"layer": "satellites",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"type": "highlight_entities",
|
||||
"domain": "satellites",
|
||||
"entity_ids": ["satellite:norad:12345"],
|
||||
"style": {
|
||||
"color": "#7dd3fc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "open_result_panel",
|
||||
"title": "北斗卫星",
|
||||
"items": []
|
||||
}
|
||||
],
|
||||
"matched_entities": [],
|
||||
"confidence": 0.86,
|
||||
"missing_data": []
|
||||
}
|
||||
```
|
||||
|
||||
### Speech / ASR
|
||||
|
||||
Add:
|
||||
|
||||
```text
|
||||
POST /api/v1/agents/speech/transcriptions
|
||||
```
|
||||
|
||||
Request should use multipart form data:
|
||||
|
||||
- `file`: audio blob.
|
||||
- `language`: default `zh`.
|
||||
- `provider`: optional provider override.
|
||||
- `source`: default `earth_command`.
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "高亮所有北斗卫星",
|
||||
"provider": "openai_compatible",
|
||||
"model": "whisper-1",
|
||||
"duration_ms": 1234,
|
||||
"confidence": null,
|
||||
"metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
## Earth Action Plan
|
||||
|
||||
### Allowed Action Types
|
||||
|
||||
The first version may only return:
|
||||
|
||||
```text
|
||||
show_layer
|
||||
highlight_entities
|
||||
filter_entities
|
||||
focus_view
|
||||
open_result_panel
|
||||
clear_highlight
|
||||
```
|
||||
|
||||
### Allowed Domains
|
||||
|
||||
The first version supports:
|
||||
|
||||
```text
|
||||
satellites
|
||||
compute_centers
|
||||
bgp
|
||||
news
|
||||
vessels
|
||||
cables
|
||||
```
|
||||
|
||||
### EarthAction Shape
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "highlight_entities",
|
||||
"domain": "satellites",
|
||||
"entity_ids": ["satellite:norad:12345"],
|
||||
"style": {
|
||||
"color": "#7dd3fc",
|
||||
"mode": "glow"
|
||||
},
|
||||
"reason": "用户要求高亮北斗卫星"
|
||||
}
|
||||
```
|
||||
|
||||
### Safety Rules
|
||||
|
||||
The backend must validate action plans before returning them:
|
||||
|
||||
- `type` must be in the allowed action list.
|
||||
- `domain` must be in the allowed domain list.
|
||||
- `entity_ids` must come from backend or current Earth candidate data. The model may not invent ids.
|
||||
- One highlight action should include at most 500 entities by default. If more match, return a truncation note in `missing_data` or `summary`.
|
||||
- `focus_view` must include valid coordinates, a valid region, or a matched entity.
|
||||
- No action may return executable JavaScript, arbitrary CSS, or arbitrary URLs to fetch.
|
||||
|
||||
## Earth Entity Query
|
||||
|
||||
Add a backend entity query service used by both deterministic resolvers and LLM tools.
|
||||
|
||||
### Satellites
|
||||
|
||||
Sources:
|
||||
|
||||
- `/api/v1/visualization/geo/satellites`.
|
||||
- Current TLE collected data.
|
||||
- GeoJSON feature properties.
|
||||
|
||||
Filters:
|
||||
|
||||
- `constellation`: `beidou`, `gps`, `galileo`, `glonass`, `starlink`, `iridium`, `geo`, `leo`.
|
||||
- Name contains.
|
||||
- NORAD id.
|
||||
- Country/operator when present in data.
|
||||
- Orbital class when inferable from existing fields.
|
||||
|
||||
Beidou matching:
|
||||
|
||||
- Prefer `constellation == beidou`.
|
||||
- Then match names containing `BEIDOU`, `BDS`, `BEIDOU-`, or `北斗`.
|
||||
- Stable entity id format should be `satellite:norad:{norad_id}` when possible, with fallback `satellite:index:{index}`.
|
||||
|
||||
### Compute Centers
|
||||
|
||||
Sources:
|
||||
|
||||
- `/api/v1/visualization/geo/compute-centers`.
|
||||
- Unified TOP500 and Epoch AI GPU GeoJSON properties.
|
||||
|
||||
Filters:
|
||||
|
||||
- Country/region.
|
||||
- `site_type`: `supercomputer` or `gpu_cluster`.
|
||||
- Source: `top500` or `epoch_ai_gpu`.
|
||||
- Name contains.
|
||||
- `needs_confirmation`.
|
||||
- `location_precision`.
|
||||
|
||||
Mainland China matching:
|
||||
|
||||
- Match country values such as `China`, `中国`, or `People's Republic of China`.
|
||||
- Exclude obvious non-mainland records when fields identify Hong Kong, Macau, or Taiwan.
|
||||
- If records do not expose enough region detail to separate mainland China from Hong Kong, Macau, or Taiwan, return a `missing_data` note and conservatively match `country=China`.
|
||||
|
||||
### BGP, News, Vessels, And Cables
|
||||
|
||||
First-version basic support:
|
||||
|
||||
- BGP: severity, status, region, collector, prefix, ASN.
|
||||
- News: region, source, localized title, localized summary.
|
||||
- Vessels: vessel type, country/area, name, status.
|
||||
- Cables: cable name, landing point, country/region.
|
||||
|
||||
## Earth Frontend Integration
|
||||
|
||||
### Search Panel Merge
|
||||
|
||||
Reuse the existing Earth search panel:
|
||||
|
||||
- Default behavior remains normal local search.
|
||||
- Add an AI command state for natural-language commands.
|
||||
- Add a "use AI" command button.
|
||||
- Add a microphone button.
|
||||
- Show running status, result summary, and clear-highlight action.
|
||||
- Visually distinguish ordinary search results from AI action results.
|
||||
|
||||
Natural-language routing:
|
||||
|
||||
- If the user clicks the AI command button, always call the AI command endpoint.
|
||||
- If input contains action words such as `高亮`, `显示`, `找出`, `聚焦`, `打开`, `筛选`, `隐藏`, or `清除`, suggest AI command mode.
|
||||
- Short ordinary keywords continue to use local search.
|
||||
|
||||
### Earth Action Executor
|
||||
|
||||
Add:
|
||||
|
||||
```text
|
||||
frontend/public/earth/js/earth-command-actions.js
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Receive backend `EarthActionPlan`.
|
||||
- Toggle required layers.
|
||||
- Apply highlights and filters.
|
||||
- Focus view when requested.
|
||||
- Open or update the result panel.
|
||||
- Clear the previous AI command highlight.
|
||||
|
||||
Execution order:
|
||||
|
||||
1. Clear previous AI command highlights unless the plan is explicitly additive.
|
||||
2. Show required layers.
|
||||
3. Apply filters and highlights.
|
||||
4. Focus the view if requested.
|
||||
5. Open the result panel.
|
||||
6. Record the execution summary in UI state.
|
||||
|
||||
### Satellite Highlight
|
||||
|
||||
Existing `highlightRelatedSatellites(indices, color)` can be reused, but Earth command support needs an entity-id-to-index map.
|
||||
|
||||
Add:
|
||||
|
||||
- Satellite id map built from current satellite data.
|
||||
- Highlight by `entity_ids`.
|
||||
- Clear AI command satellite highlights without clearing manual locked selection.
|
||||
- Auto-enable the satellite layer before highlighting.
|
||||
|
||||
### Compute Center Highlight
|
||||
|
||||
Existing compute center markers support marker state, but need a dedicated batch AI highlight state.
|
||||
|
||||
Add:
|
||||
|
||||
- Batch set compute center markers to AI highlighted state.
|
||||
- Keep AI highlight compatible with hover and locked states.
|
||||
- Clear AI command compute highlights without clearing manual locked selection.
|
||||
- Auto-enable the compute-center layer before highlighting.
|
||||
|
||||
### Result Panel
|
||||
|
||||
First version can show results inside the merged search panel:
|
||||
|
||||
- Title, for example `北斗卫星`.
|
||||
- Count, for example `已高亮 32 个对象`.
|
||||
- List with the first 20 entities.
|
||||
- Actions: clear highlight, rerun, view Agent Run.
|
||||
|
||||
## Voice And Wake Word
|
||||
|
||||
### ASR Configuration
|
||||
|
||||
Add Speech/ASR under the AI settings tool tab.
|
||||
|
||||
Fields:
|
||||
|
||||
- `enabled`.
|
||||
- `provider`.
|
||||
- `base_url`.
|
||||
- `api_key`.
|
||||
- `model`.
|
||||
- `language`.
|
||||
- `timeout_seconds`.
|
||||
- `max_audio_size_mb`.
|
||||
|
||||
First-version providers:
|
||||
|
||||
- `openai_whisper`.
|
||||
- `openai_compatible`.
|
||||
- `local_whisper`.
|
||||
|
||||
Recommended defaults:
|
||||
|
||||
- `provider`: `openai_compatible`.
|
||||
- `model`: `whisper-1` or user-configured equivalent.
|
||||
- `language`: `zh`.
|
||||
|
||||
Secret handling:
|
||||
|
||||
- Use the existing masked secret pattern.
|
||||
- Do not allow agent proposals to write API keys.
|
||||
- Support environment fallback such as `ASR_API_KEY` and `OPENAI_API_KEY`.
|
||||
|
||||
### Wake Word
|
||||
|
||||
The wake word is configurable.
|
||||
|
||||
Implementation rules:
|
||||
|
||||
- Treat wake word as a browser-local preference in v1.
|
||||
- Store it in `localStorage`.
|
||||
- Default suggestion: `小星球`.
|
||||
- Provide a wake-word setting inside the Earth search/command panel.
|
||||
- Do not listen until the user explicitly enables voice wake.
|
||||
- Do not upload audio before wake.
|
||||
- After wake, record one instruction audio segment and upload it to backend ASR.
|
||||
|
||||
Fallbacks:
|
||||
|
||||
- Browser does not support continuous local recognition: fall back to click-to-record.
|
||||
- Microphone permission denied: show `麦克风权限未开启,仍可输入文字指令`.
|
||||
- ASR not configured: show `语音识别未配置`; text commands remain available.
|
||||
- User can disable wake listening and keep manual microphone recording.
|
||||
|
||||
### Wake Word Technical Choice
|
||||
|
||||
Do not require local Whisper in v1.
|
||||
|
||||
Recommended path:
|
||||
|
||||
- Use browser Web Speech API for local wake-word detection when available.
|
||||
- Fall back to click-to-record when unavailable.
|
||||
- Upload only the post-wake instruction audio to backend ASR.
|
||||
- Add local `whisper.cpp` streaming wake-word or ASR provider later.
|
||||
|
||||
## Prompt Registry
|
||||
|
||||
Add default prompt keys:
|
||||
|
||||
```text
|
||||
agents.runtime.system
|
||||
agents.earth.command
|
||||
agents.situational.assessment
|
||||
agents.config.proposal
|
||||
agents.roles.network
|
||||
agents.roles.bgp
|
||||
agents.roles.platform_ops
|
||||
agents.roles.business_impact
|
||||
```
|
||||
|
||||
`agents.earth.command` must require:
|
||||
|
||||
- Strict JSON output only.
|
||||
- Use only provided candidate entities.
|
||||
- Do not invent objects.
|
||||
- Do not return arbitrary code.
|
||||
- Do not modify business data.
|
||||
- Return `clarification_needed` when intent is unclear.
|
||||
- Return action plans matching the schema.
|
||||
- Use Chinese summary for Chinese user input.
|
||||
|
||||
## Policy
|
||||
|
||||
### Tool Policy
|
||||
|
||||
- The LLM can request tools, but the backend decides whether to execute.
|
||||
- Every tool must declare:
|
||||
- name.
|
||||
- description.
|
||||
- input schema.
|
||||
- output schema.
|
||||
- permission.
|
||||
- side-effect level.
|
||||
- First-version side-effect levels:
|
||||
- `read`: can run directly.
|
||||
- `proposal`: can only generate proposals.
|
||||
- `write`: can only execute during proposal apply.
|
||||
|
||||
### Proposal Policy
|
||||
|
||||
First version supports configuration proposal application with role-based automatic/manual gating:
|
||||
|
||||
- `super_admin` may enable automatic application for low-risk proposals.
|
||||
- Normal admins must manually confirm.
|
||||
- High-risk proposals always require manual confirmation.
|
||||
- Proposals containing secret fields are rejected.
|
||||
- Proposals failing schema validation are rejected.
|
||||
- Before and after payloads must be saved.
|
||||
- Failed applications must save errors.
|
||||
|
||||
Low-risk scope:
|
||||
|
||||
- Datasource endpoint/config non-secret fields.
|
||||
- AI prompt overrides.
|
||||
- External integration non-secret fields.
|
||||
|
||||
Out of scope for v1:
|
||||
|
||||
- Alert acknowledge/resolve.
|
||||
- Data deletion.
|
||||
- User permission changes.
|
||||
- Authentication configuration changes.
|
||||
- Database schema changes by agent.
|
||||
- Mutation of original Earth collected data.
|
||||
|
||||
## Agent Operations UI
|
||||
|
||||
Add an `Agent` page under `运维与配置`.
|
||||
|
||||
Run list:
|
||||
|
||||
- Status.
|
||||
- Type.
|
||||
- Title.
|
||||
- Creator.
|
||||
- Model.
|
||||
- Time.
|
||||
- Duration.
|
||||
|
||||
Run detail:
|
||||
|
||||
- Input.
|
||||
- Status timeline.
|
||||
- LLM steps.
|
||||
- Tool steps.
|
||||
- Evidence.
|
||||
- Final result.
|
||||
- Proposals.
|
||||
|
||||
Proposal apply:
|
||||
|
||||
- Before/after diff.
|
||||
- Risk level.
|
||||
- Policy result.
|
||||
- Apply button.
|
||||
- Reject button.
|
||||
|
||||
Earth command run detail:
|
||||
|
||||
- Original text or speech transcription.
|
||||
- Matched entities.
|
||||
- Action plan.
|
||||
- Link back to Earth or copy run id.
|
||||
|
||||
## Frontend Types
|
||||
|
||||
Add or extend:
|
||||
|
||||
```ts
|
||||
interface AgentRun {}
|
||||
interface AgentStep {}
|
||||
interface AgentEvidence {}
|
||||
interface AgentProposal {}
|
||||
interface EarthCommandRequest {}
|
||||
interface EarthActionPlan {}
|
||||
interface EarthAction {}
|
||||
interface SpeechTranscriptionResponse {}
|
||||
```
|
||||
|
||||
Earth action executor API:
|
||||
|
||||
```js
|
||||
executeEarthActionPlan(plan, context)
|
||||
clearEarthCommandHighlights()
|
||||
getEarthCommandExecutionState()
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend Tests
|
||||
|
||||
Use `uv`.
|
||||
|
||||
Suggested files:
|
||||
|
||||
```text
|
||||
backend/tests/test_agents_runtime.py
|
||||
backend/tests/test_agent_tool_protocol.py
|
||||
backend/tests/test_agent_policy.py
|
||||
backend/tests/test_earth_agent_command.py
|
||||
backend/tests/test_speech_transcription.py
|
||||
```
|
||||
|
||||
Coverage:
|
||||
|
||||
- Create run.
|
||||
- Persist ordered run steps.
|
||||
- Parse valid JSON tool calls.
|
||||
- Reject invalid JSON tool calls without executing tools.
|
||||
- Reject unregistered tools.
|
||||
- Reject invalid tool arguments.
|
||||
- Pass through provider-native tools fields when configured.
|
||||
- Fall back to JSON tool calls when native tools are unavailable.
|
||||
- When WebSearch is not configured, record `missing_data` and do not fail Earth command.
|
||||
- "高亮所有北斗卫星" returns a satellite highlight action.
|
||||
- "中国大陆的算力中心" returns a compute-center highlight action.
|
||||
- Mainland China matching excludes Hong Kong, Macau, and Taiwan when fields permit it.
|
||||
- Field granularity limitations are returned in `missing_data`.
|
||||
- ASR not configured returns a clear error.
|
||||
- ASR provider success returns transcription.
|
||||
- Normal admins cannot auto-apply proposals.
|
||||
- `super_admin` can apply low-risk proposals.
|
||||
- Secret-field proposals are rejected.
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
Use `bun`.
|
||||
|
||||
Required build check:
|
||||
|
||||
```bash
|
||||
cd frontend && bun run build
|
||||
```
|
||||
|
||||
Suggested Earth JS tests:
|
||||
|
||||
- Action executor opens the satellite layer.
|
||||
- Action executor highlights satellite entity ids.
|
||||
- Action executor highlights compute-center entity ids.
|
||||
- Clear highlight does not clear manual locked selection.
|
||||
- Search panel ordinary search still uses local search.
|
||||
- AI command button calls `/api/v1/agents/earth/command`.
|
||||
- ASR missing configuration and microphone permission denial show clear UI feedback.
|
||||
|
||||
### Manual Acceptance
|
||||
|
||||
1. Type `高亮所有北斗卫星`.
|
||||
- Satellite layer opens.
|
||||
- Beidou satellites are highlighted.
|
||||
- Panel shows count and summary.
|
||||
- Agent run is reviewable.
|
||||
|
||||
2. Type `显示中国大陆的算力中心`.
|
||||
- Compute-center layer opens.
|
||||
- Mainland China related compute centers are highlighted.
|
||||
- If data cannot separate mainland China from Hong Kong/Macau/Taiwan, the UI shows a missing-data note.
|
||||
|
||||
3. Click microphone.
|
||||
- Permission denial is clear.
|
||||
- Permission grant allows recording.
|
||||
- Configured ASR transcribes and executes the command.
|
||||
- If ASR is not configured, text input still works.
|
||||
|
||||
4. Enable voice wake.
|
||||
- Wake word is configurable.
|
||||
- Audio is not uploaded before wake.
|
||||
- After wake, one instruction audio segment is uploaded.
|
||||
- Wake listening can be disabled.
|
||||
|
||||
5. Open Agent operations UI.
|
||||
- Earth command run is listed.
|
||||
- Transcription step is visible when speech was used.
|
||||
- Entity query step is visible.
|
||||
- Final action plan is visible.
|
||||
- User can return to Earth or copy the run id.
|
||||
|
||||
## Documentation
|
||||
|
||||
When implemented, update:
|
||||
|
||||
```text
|
||||
docs/technical/zh/agents-aiprovider.md
|
||||
docs/technical/en/agents-aiprovider.md
|
||||
docs/technical/zh/manual.md
|
||||
docs/technical/en/manual.md
|
||||
docs/technical/zh/earth-frontend-context.md
|
||||
docs/technical/en/earth-frontend-context.md
|
||||
```
|
||||
|
||||
Document:
|
||||
|
||||
- `aiprovider` and backend agent boundaries.
|
||||
- Earth AI command entry.
|
||||
- Speech/ASR configuration.
|
||||
- Wake-word privacy behavior.
|
||||
- Agent run/evidence/proposal review.
|
||||
- V1 capability boundary: Earth visualization actions only, no business data mutation.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add backend Agent data models, `init_db()` imports, and indexes.
|
||||
2. Add Agent schemas and run CRUD API.
|
||||
3. Add tool registry, JSON tool-call parser, and policy skeleton.
|
||||
4. Add Earth entity query service.
|
||||
5. Add `agents.earth.command` prompt and backend command endpoint.
|
||||
6. Add frontend Earth action executor.
|
||||
7. Merge AI command entry into Earth search panel.
|
||||
8. Add satellite and compute-center batch highlight support.
|
||||
9. Add Speech/ASR settings and transcription API.
|
||||
10. Add Earth microphone recording, wake-word local setting, and fallback behavior.
|
||||
11. Add Agent operations UI.
|
||||
12. Add proposal apply policy and low-risk configuration application.
|
||||
13. Add tests and documentation updates.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Earth LLM v1 only executes visualization actions.
|
||||
- Earth commands create agent runs but do not automatically create configuration proposals.
|
||||
- Configuration proposals are applied only from the Agent operations UI.
|
||||
- Wake word is a device-local preference in v1 and is stored in `localStorage`.
|
||||
- ASR is API-first and Whisper-compatible by default; local Whisper is a later provider.
|
||||
- Multi-role simulation only reserves schemas and prompt keys in v1.
|
||||
- Palantir-style situational workflows should grow from the shared evidence, entity, action, and assessment model instead of a separate isolated system.
|
||||
52
docs/plans/ai-prompt-settings-task-registry-plan.md
Normal file
52
docs/plans/ai-prompt-settings-task-registry-plan.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# AI Prompt Settings and Task Registry Plan
|
||||
|
||||
## Summary
|
||||
|
||||
Add an AI prompt settings tab under the operations AI settings page. Operators can select a business AI task from a dropdown, edit its prompt, save the override, and reset it back to the shipped default. Runtime LLM calls must resolve prompts through a task registry instead of embedding large prompt blocks at each call site.
|
||||
|
||||
Default prompts are shipped as versioned resource data, not scattered business-code literals. Business services reference stable task keys, and the runtime resolves the effective prompt from the database override first, then the shipped default resource.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Add a backend task prompt registry with stable keys, labels, groups, versions, default system prompts, and default task prompts.
|
||||
- Store operator overrides in the existing `SystemSetting` table under an `ai_prompts` category. Store only custom overrides; defaults remain in the versioned prompt resource.
|
||||
- Add settings APIs:
|
||||
- `GET /api/v1/settings/ai-prompts`
|
||||
- `PUT /api/v1/settings/ai-prompts/{task_key}`
|
||||
- `POST /api/v1/settings/ai-prompts/{task_key}/reset`
|
||||
- Migrate business LLM entrypoints to resolve prompts by task key. `aiprovider` remains a pure model adapter and does not inject business prompts.
|
||||
- Add a “提示词” tab to `/ai`. The tab shows a grouped task dropdown, current/default prompt status, editable prompt fields, save, and reset-to-default controls.
|
||||
|
||||
## Initial Tasks
|
||||
|
||||
- `earth.news.enrich` — Earth news localization and location enrichment.
|
||||
- `alerts.brief` — system alert AI brief.
|
||||
- `alerts.situational.brief` — situational alert AI brief.
|
||||
- `bgp.brief` — BGP AI brief.
|
||||
- `location.factcheck.normalize` — location factcheck normalization.
|
||||
- `location.factcheck.resolve` — location factcheck fallback resolution.
|
||||
- `datasource.mapping` — datasource mapping DSL generation.
|
||||
- `credential.guide` — credential guide generation.
|
||||
- `ai.connection_test` — AI provider connection test.
|
||||
|
||||
Playground and public free-form analyze endpoints stay caller-controlled and are not shown in the prompt settings dropdown.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Backend uses `uv`:
|
||||
- `uv run pytest tests/test_settings_ai_prompts.py`
|
||||
- `uv run pytest tests/test_earth_news.py`
|
||||
- `uv run pytest tests/test_api.py`
|
||||
- Frontend uses `bun`:
|
||||
- `cd frontend && bun run build`
|
||||
- Manual checks:
|
||||
- Prompt dropdown switches task content correctly.
|
||||
- Save persists an override and runtime calls use it.
|
||||
- Reset deletes the override and restores the shipped default.
|
||||
- Alert prompts do not leak into news, BGP, datasource, or location tasks.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- This iteration does not add prompt history, approval workflows, A/B testing, or per-user prompt variants.
|
||||
- Strict JSON tasks may fail validation if an operator edits away the output contract; existing task-specific failure and retry behavior remains responsible for recovery.
|
||||
- The UI stays Chinese-only in this iteration.
|
||||
@@ -31,7 +31,7 @@
|
||||
3. **登录与找回密码** — 登录页、忘记密码流程
|
||||
4. **账户设置** — 修改密码、修改邮箱(需重新验证)、查看权限组、登出
|
||||
5. **Console 总览** — 左侧菜单结构、各路由用途
|
||||
6. **配置数据采集器** — `/settings?tab=collector_credentials`:选择 collector、连接测试、保存凭证;BarentsWatch / AISStream 两个典型例子
|
||||
6. **配置数据采集器** — `/collection-management?tab=collector_credentials`:选择 collector、连接测试、保存凭证;BarentsWatch / AISStream 两个典型例子
|
||||
7. **配置 AI 凭证** — `/ai?tab=providers`:默认 provider、模型、Base URL、API Key、本地代理;工具 tab(WebSearch、OCR)
|
||||
8. **系统设置** — `/settings` 其他子 tab(系统设置、电视直播源、SMTP 邮件)
|
||||
9. **用户管理(管理员)** — `/users`:创建、删除、改角色、Gatekeeper 权限组
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
- 打开管理员给你的 URL
|
||||
- 注册账号 + 邮箱验证
|
||||
- 登录后第一次做什么(建议先到 `/settings?tab=collector_credentials` 配一个 collector,再到 `/ai` 配模型)
|
||||
- 登录后第一次做什么(建议先到 `/collection-management?tab=collector_credentials` 配一个 collector,再到 `/ai` 配模型)
|
||||
- 看 Earth
|
||||
|
||||
部署/开发的 quickstart 内容并入 `ops-runbook.md` 的"首次部署"小节,**不**再单独出 `ops-quickstart.md`,避免新增维护点。
|
||||
|
||||
@@ -1,132 +1,71 @@
|
||||
# Earth High Precision Boundary Tiles Plan
|
||||
# Earth High Precision Boundary PMTiles Plan
|
||||
|
||||
## Status
|
||||
|
||||
Planning revised after visual review. The previous hand-authored China claim-line / point-buffer approach is rejected and must not be implemented.
|
||||
Superseded status:
|
||||
|
||||
Implemented separately:
|
||||
This plan originally treated boundaries as collector-managed source records. The current implementation has moved country boundaries out of the datasource / collector lifecycle. Boundaries are now Earth static rendering assets managed by `Operations and Configuration -> Earth Content -> Boundary Precision` and `/api/v1/earth/boundaries/*`. The bundled low-precision GeoJSON is the default fallback, and high precision is an opt-in local PMTiles build.
|
||||
|
||||
- 地表 hover 三模式已实现。
|
||||
Historical implementation notes below are retained only as context and must not be used as the current architecture:
|
||||
|
||||
Still planned:
|
||||
- Three standard source collectors now handle real source ingestion: `earth_admin0_boundaries`, `earth_coastline`, and `earth_claim_lines`.
|
||||
- Each source collector reads endpoint / headers / auth / `target_schema=earth_boundary_source` from Collector Settings, downloads the configured payload, stores the full artifact under `data/earth-boundary-sources/<collector>/<sha256>.*`, and writes a hash / feature-count / artifact-path record to `CollectedData`.
|
||||
- The backend `earth_boundary_tiles` item is now a downstream PMTiles builder. It refuses to run until the three source records exist, then skips rebuilds when source / POV policy / build config are unchanged.
|
||||
- The frontend boundary layer now requires the production `pmtiles-mvt` provider and no longer falls back to legacy low-precision GeoJSON.
|
||||
- Generated loose boundary data is ignored by Git and is not the production deployment format.
|
||||
- Production PMTiles builds require external `tippecanoe` and `pmtiles` CLIs; missing tools fail the builder clearly instead of registering fake tile records.
|
||||
|
||||
- 权威 China POV 数据包。
|
||||
- OSM / coastline 高精度离线构建。
|
||||
- 版本化静态矢量瓦片输出。
|
||||
- 前端 bbox/tile/LRU 高精度加载器。
|
||||
Still required before claiming true one-to-one high precision:
|
||||
|
||||
- Replace the repository seed GeoJSON with audited high-precision admin boundary, coastline, and claim-line source packages.
|
||||
- Run a real geometry preparation step that applies the China POV policy through union / subtract / validity repair before PMTiles creation.
|
||||
- Build and publish `earth-boundaries-china-pov-v1.pmtiles` plus its manifest.
|
||||
|
||||
## Summary
|
||||
|
||||
Earth 国界线目标从“明显提升”升级为 **最高精度档按真实地图源一比一还原**:
|
||||
The Earth boundary layer should use one static PMTiles archive containing MVT tiles instead of thousands of loose GeoJSON files. The artifact is POV-specific: `earth-boundaries-china-pov-v1.pmtiles` has China POV baked in during offline source preparation, and the browser never patches political boundaries at runtime.
|
||||
|
||||
- 高缩放时不能使用当前 `countries-admin0.min.geojson` 这种低精度简化线。
|
||||
- 最高 tile zoom 必须忠实保留选定权威矢量源的原始折点,不做视觉平滑,不做人工凭感觉补线。
|
||||
- 藏南、阿克赛钦等争议陆地直接作为中国国家面的一部分表达;hover 只显示普通 `中国 / CHN`,不显示特殊区域名。
|
||||
- 九段线 / 十段线必须来自官方标准地图口径或经地理配准校核后的权威矢量数据;不能手工目测画线,不能把马来西亚、菲律宾等周边陆地或近岸底盘划入中国面。
|
||||
Production should serve a single PMTiles artifact through static hosting and HTTP range requests. In development or on machines that have not opted into high precision, missing PMTiles falls back to the bundled low-precision GeoJSON so the Earth base remains usable.
|
||||
|
||||
方案仍采用 **离线构建 + 静态矢量瓦片**。低缩放加载简化 base line;高缩放按当前视野 bbox 加载高精度 boundary tiles。第一版不使用 Redis,依靠 Nginx 静态服务、浏览器 HTTP cache 和前端 LRU。
|
||||
## Key Implementation Rules
|
||||
|
||||
## Data Source and Policy
|
||||
- Source inputs must be auditable. OSM admin boundaries, coastline packages, and claim-line endpoints are configured through Earth Content boundary precision settings; `config/earth-boundary-sources.example.json` remains the versioned example template.
|
||||
- China POV geometry is applied before tiling:
|
||||
- Zangnan and Aksai Chin are unioned into China and subtracted from India.
|
||||
- Taiwan/Penghu, Diaoyu Dao and affiliated islands, Chiwei Yu, and South China Sea islands are China hover/country features.
|
||||
- The South China Sea dashed line is a claim-line layer only; it never consumes Malaysian, Philippine, Vietnamese, or other land polygons.
|
||||
- Kosovo is not an independent country surface in this profile; Gaza is a Palestine region.
|
||||
- PMTiles/MVT layer names are fixed for the frontend:
|
||||
- `boundary_admin0`
|
||||
- `boundary_disputed_internal`
|
||||
- `coastline`
|
||||
- `claim_line`
|
||||
- The frontend provider is selected from local high-precision preference plus the boundary manifest:
|
||||
- `tileProvider: "pmtiles-mvt"` reads the PMTiles artifact.
|
||||
- Missing high-precision preference, missing manifest, or missing PMTiles artifact falls back to low-precision GeoJSON.
|
||||
- Redis is not part of v1. Static PMTiles plus browser/CDN range caching is the default performance model.
|
||||
|
||||
- 基础陆地国界使用 OSM `boundary=administrative` + `admin_level=2`,并补充高精度 coastline,避免只靠粗糙国家面导致海岸线缺失。
|
||||
- China POV 覆盖数据必须独立成包,构建时优先级高于 OSM 原始归属:
|
||||
- 藏南 union 到中国面,同时从印度面 subtract。
|
||||
- 阿克赛钦 union 到中国面,同时从相关邻接面 subtract。
|
||||
- 台湾、澎湖、钓鱼岛及附属岛屿、赤尾屿、东沙、西沙、中沙、南沙等作为中国国家面/岛礁面的一部分进入 hover index。
|
||||
- 岛礁很小时可有最小可交互面,但 tooltip 仍是 `中国 / CHN`,不展示“某特殊区域归属”标签。
|
||||
- 九段线 / 十段线是独立 maritime claim line 图层:
|
||||
- 只渲染 dashed line,不参与国家陆地面。
|
||||
- 不用于吞并周边国家陆地或近岸水域。
|
||||
- 坐标必须来自官方标准地图、权威矢量数据,或从官方示意图配准后人工复核,不接受手工猜测坐标。
|
||||
- 必须保留 OSM 数据归因:`© OpenStreetMap contributors, ODbL`。
|
||||
## Cleanup And Documentation
|
||||
|
||||
## Precision Requirements
|
||||
|
||||
- 最高精度档的验收口径是 **source-faithful**,不是“看起来更细”:
|
||||
- 对高精度源线,最高 zoom tile 不允许 Douglas-Peucker 简化。
|
||||
- 坐标量化精度至少保留到 `1e-5` 度级别,构建时不得把经纬度粗暴四舍五入到低精度。
|
||||
- 球面渲染只允许 densify 长边来贴合地球曲率;不允许 CatmullRom、Bezier 或任何会改变边界走向的平滑。
|
||||
- 海岸/边界红框类区域必须与源地图折线逐点对齐;若有偏差,只能追溯并替换数据源,不能靠渲染平滑掩盖。
|
||||
- 低缩放允许简化,但必须有误差预算:
|
||||
- base line 只服务远景识别。
|
||||
- 中 zoom tile 可简化到屏幕误差低于 `0.5px`。
|
||||
- 最高 zoom tile 使用无简化或近零误差版本。
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### 1. Data pipeline
|
||||
|
||||
新增边界构建脚本,使用 `/home/ray/.local/bin/uv` 运行:
|
||||
|
||||
1. 读取 OSM PBF 或预处理后的 admin-0 边界 / coastline GeoJSON。
|
||||
2. 读取 China POV override package。
|
||||
3. 使用可靠几何库做 union / difference / validity repair,禁止手写 polygon overlay。
|
||||
4. 生成中国国家面时直接合并藏南、阿克赛钦和相关岛礁;从相邻国家面扣除同一区域。
|
||||
5. 单独读取官方口径九段线 / 十段线矢量,生成 claim-line tiles。
|
||||
6. 输出:
|
||||
- 低精度 global base line。
|
||||
- 无简化 high-precision hover polygon index。
|
||||
- 高精度静态瓦片:`frontend/public/earth/data/boundaries/v1/{z}/{x}/{y}.geojson`。
|
||||
- 海上断续线瓦片:`frontend/public/earth/data/boundaries/v1/china-claims/{z}/{x}/{y}.geojson`。
|
||||
- manifest,记录数据源、覆盖规则版本、构建时间、简化误差和 attribution。
|
||||
|
||||
### 2. Tile levels and size budget
|
||||
|
||||
- Earth zoom `< 1.6`:只显示 global base line 和低精度 claim line。
|
||||
- Earth zoom `1.6-2.8`:加载 tile zoom `4-5`。
|
||||
- Earth zoom `2.8-4.0`:加载 tile zoom `6-7`。
|
||||
- Earth zoom `> 4.0`:加载 tile zoom `8-10`,使用最高精度无简化折线。
|
||||
- 单 tile gzip 目标 `20-80KB`,但最高精度档优先保证几何真实性;若超限,优先提高 tile zoom 或拆 tile,而不是简化真实线。
|
||||
|
||||
### 3. Frontend loading model
|
||||
|
||||
`country-boundaries.js` 拆成 base layer、tile layer 和 China claim layer:
|
||||
|
||||
- 首屏加载 base line + hover index,不阻塞 Earth 初始化。
|
||||
- 高缩放时根据 camera 可见范围计算经纬 bbox,再转换为 Web Mercator tile keys。
|
||||
- bbox 由屏幕中心、四角和边中点 raycast 得到,并扩张 `10-20%` 作为预取范围。
|
||||
- 处理反经线,必要时拆成两个 bbox。
|
||||
- 视野变化请求 debounce `150-250ms`。
|
||||
- 拖拽/惯性旋转中不每帧请求;缩放档或 tile key 集合没变时不刷新。
|
||||
- 加载当前视野 tile,并预取一圈邻接 tile。
|
||||
- base line 在高精度 tile 到达后降低 opacity,避免双线。
|
||||
|
||||
### 4. Caching and memory control
|
||||
|
||||
- 前端维护 `tileCache`、`inFlightTiles` 和 LRU 使用顺序。
|
||||
- tile cache 上限建议 `120-180` 个 tile;超过后释放最旧 tile 的 `BufferGeometry`。
|
||||
- Nginx 为 `/earth/data/boundaries/` 设置长缓存:
|
||||
- `Cache-Control: public, max-age=31536000, immutable`
|
||||
- gzip 包含 JSON。
|
||||
- tile URL 包含版本目录,例如 `v1`;数据更新时改版本目录破浏览器缓存。
|
||||
- 第一版不使用 Redis。只有改成动态裁剪 API、多 POV 同 URL、或压测证明静态服务成为瓶颈时再考虑 Redis。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. 最高 zoom 的海岸线和国界线与选定源地图逐点一致,不再只是“明显提升”。
|
||||
2. 红框类海岸/边界细节在最高 zoom 下不能出现肉眼可见的低精度折线、直线切边或圆滑失真。
|
||||
3. 藏南、阿克赛钦 hover 命中普通 `中国 / CHN`;印度或其他邻接国家不再包含这些区域。
|
||||
4. 钓鱼岛、赤尾屿、南海诸岛代表点 hover 命中普通 `中国 / CHN`。
|
||||
5. 九段线 / 十段线位置与权威来源一致,不压入马来西亚、菲律宾等周边陆地或错误包围近岸底盘。
|
||||
6. 首屏不加载全球超高精度整包。
|
||||
7. 高缩放只请求当前视野附近 tile,快速旋转不会出现请求风暴。
|
||||
8. 回到已访问区域命中前端 cache 或浏览器 cache。
|
||||
9. 国界图层开关、hover 高亮、移动端中心国家高亮、悬停提示三模式保持可用。
|
||||
- Do not commit generated loose tiles under `frontend/public/earth/data/boundaries/` or source downloads under `data/earth-boundary-sources/`.
|
||||
- Remove stale generated debug data before production builds; regenerate it only when smoke testing the debug path.
|
||||
- Keep the high-level plan, backend collector docs, layer style docs, and ops runbook aligned whenever the provider contract changes.
|
||||
- After implementation changes, provide user-facing operation steps covering source configuration, artifact build/deploy, page verification, and fallback troubleshooting.
|
||||
|
||||
## Verification
|
||||
|
||||
- 构建阶段:
|
||||
- 几何 validity check 全通过。
|
||||
- China POV 代表点测试全部返回 `CHN`。
|
||||
- 相邻国家代表点不得被 China override 误吞。
|
||||
- 最高 zoom tile 抽样与源数据做坐标级 diff,确认未简化。
|
||||
- 前端阶段:
|
||||
- 在 `frontend` 运行 `/home/ray/.bun/bin/bun run build`。
|
||||
- 浏览器 DevTools 验证低缩放无高精度 tile 请求,高缩放请求数量受控,重复视野走 cache。
|
||||
- 用截图中的红框区域、南海断续线、藏南、钓鱼岛、赤尾屿、南海诸岛做手动视觉验收。
|
||||
- The Earth boundary build API reports missing source configuration or missing tools clearly, without creating datasource collection records.
|
||||
- The PMTiles builder fails as not ready when source artifacts exist but `tippecanoe` / `pmtiles` are missing.
|
||||
- Running the PMTiles builder twice returns `unchanged` on the second run when inputs are stable.
|
||||
- `git add . --dry-run` does not include generated loose boundary tiles or source downloads.
|
||||
- `/home/ray/.bun/bin/bun run build` passes in `frontend`.
|
||||
- Manual Earth checks confirm:
|
||||
- PMTiles range requests are issued only for visible tiles.
|
||||
- Boundary toggle, hover tooltip, and country highlight still work.
|
||||
- PMTiles failure reports a high-precision boundary error; machines without high-precision enabled continue drawing low-precision fallback boundaries.
|
||||
|
||||
## Sources and Assumptions
|
||||
## Assumptions
|
||||
|
||||
- 产品默认采用中国标准地图/公开地图合规口径;若后续支持多 POV,必须通过版本化数据目录隔离,不能让同一 URL 返回不同政治口径。
|
||||
- 当前仓库没有足够权威和足够精细的 China POV / 九段线矢量源,因此不能直接凭现有低精度 GeoJSON 完成“一比一还原”。
|
||||
- 实施前必须先引入或生成可审计的高精度源数据包;没有源数据时,只能实现加载框架,不能伪造边界。
|
||||
- "One-to-one" means source-faithful to the selected audited vector source, not hand-tuned to a screenshot.
|
||||
- The China POV artifact is static and versioned; no runtime region-based POV switching is planned.
|
||||
- The repository low-precision seed file is retained as the runtime fallback for country boundaries.
|
||||
|
||||
118
docs/plans/earth-high-resolution-basemap-tiles-plan.md
Normal file
118
docs/plans/earth-high-resolution-basemap-tiles-plan.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Earth High Resolution Basemap Tiles Plan
|
||||
|
||||
## Summary
|
||||
|
||||
High-precision borders now expose a separate visual problem: the vector coastline and border data are more accurate than the current raster Earth texture. The next step is a high-resolution basemap tile layer that aligns visually with the high-precision coastline instead of replacing the globe with one huge static image.
|
||||
|
||||
Do not solve this by committing a larger single world texture. A single 16K/32K raster still wastes memory, loads slowly, and becomes blurry or misaligned when zooming into coastal detail. The target architecture is viewport-based raster tiles with cache control, similar to terrain tiles.
|
||||
|
||||
## Goals
|
||||
|
||||
- Render a high-resolution Earth imagery basemap that visually matches the high-precision coastline and country boundary layer.
|
||||
- Load imagery by visible bbox / tile key instead of loading a whole-world giant texture.
|
||||
- Keep the current global texture only as a low-zoom background, not as the source of truth for coastlines at inspection zoom.
|
||||
- Let imagery failures degrade only the imagery layer; high-precision borders and hover must continue working.
|
||||
- Keep generated imagery cache out of Git.
|
||||
|
||||
## Data Sources
|
||||
|
||||
Candidate sources, in recommended order:
|
||||
|
||||
- NASA GIBS / Blue Marble / VIIRS style imagery for permissive global coverage and stable tile service behavior.
|
||||
- Sentinel-2 cloudless style public imagery if licensing and tile access are acceptable.
|
||||
- A self-hosted raster pyramid generated offline from audited global imagery if third-party online tile terms are unsuitable.
|
||||
|
||||
The selected source must document:
|
||||
|
||||
- license / attribution
|
||||
- max zoom and native resolution
|
||||
- tile matrix / projection
|
||||
- cache policy
|
||||
- whether commercial or public deployment is allowed
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
global low-zoom texture
|
||||
→ visible Earth bbox from camera raycast
|
||||
→ Web Mercator tile keys by zoom
|
||||
→ raster tile fetch/cache
|
||||
→ project tile image patches onto Earth surface
|
||||
→ high-precision coastline / border layer remains above imagery
|
||||
```
|
||||
|
||||
Implementation should mirror the existing terrain tile discipline:
|
||||
|
||||
- dedupe in-flight requests
|
||||
- LRU cache for decoded images / textures
|
||||
- debounce camera movement
|
||||
- cancel or ignore stale viewport requests
|
||||
- cap max tiles per frame / per view
|
||||
- expose loading/error diagnostics
|
||||
|
||||
## Rendering Rules
|
||||
|
||||
- The high-resolution imagery layer is visual only. It must not define country hover, coastline, or border geometry.
|
||||
- The high-precision coastline remains the visual alignment reference.
|
||||
- The border layer render order stays above the basemap imagery.
|
||||
- Low zoom may use the current global texture for speed.
|
||||
- Mid/high zoom overlays imagery tiles only for the visible region plus a small prefetch ring.
|
||||
- Do not draw decorative gradients or fake coastlines to hide mismatch.
|
||||
|
||||
## Frontend Work
|
||||
|
||||
- Add a new `basemap-imagery.js` module instead of expanding `country-boundaries.js`.
|
||||
- Add config in `constants.js`:
|
||||
- source URL template
|
||||
- attribution
|
||||
- min/max zoom
|
||||
- tile cache limit
|
||||
- debounce interval
|
||||
- opacity
|
||||
- enable/disable setting
|
||||
- Add Earth settings control:
|
||||
- `高清底图`: off / auto / on
|
||||
- default `auto`
|
||||
- Add debug counters for:
|
||||
- active tile count
|
||||
- cached tile count
|
||||
- failed tile count
|
||||
- current imagery zoom
|
||||
|
||||
## Backend / Ops Work
|
||||
|
||||
- If using a third-party tile service directly, document attribution and rate-limit behavior.
|
||||
- If proxying tiles, add backend cache with request coalescing and timeout limits.
|
||||
- If self-hosting, add an offline builder that writes ignored tile artifacts under a dedicated data directory.
|
||||
- Update Nginx static serving if self-hosted raster tiles are used.
|
||||
|
||||
## Performance Budget
|
||||
|
||||
- Desktop target: keep visible imagery tiles under a configurable cap, initially 64.
|
||||
- Mobile target: lower max zoom and tile cap by default.
|
||||
- Decode and upload textures incrementally; avoid blocking Earth startup on high-resolution imagery.
|
||||
- First Earth paint must still use the existing lightweight global texture.
|
||||
|
||||
## Verification
|
||||
|
||||
- Compare high-precision coastline against imagery in coastal areas such as southeast China, Taiwan, Hainan, the Korean peninsula, Japan, and island chains in the South China Sea.
|
||||
- Verify zooming / panning does not create visible tile thrash or long blank periods.
|
||||
- Verify failed imagery requests do not hide borders or break hover.
|
||||
- Verify memory stabilizes after repeated pan/zoom due to LRU eviction.
|
||||
- Run `/home/ray/.bun/bin/bun run build`.
|
||||
|
||||
## User Operation Steps
|
||||
|
||||
After implementation, the user should be able to:
|
||||
|
||||
1. Open Earth settings.
|
||||
2. Set `高清底图` to `auto` or `on`.
|
||||
3. Open Earth and zoom into a coastline.
|
||||
4. See imagery tiles refine under the high-precision boundary/coastline layer.
|
||||
5. Use diagnostics to confirm which imagery zoom and tile source are active.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The existing high-precision vector coastline is the alignment reference.
|
||||
- This plan improves visual texture fidelity; it does not replace the boundary data pipeline.
|
||||
- A single larger static Earth texture is rejected as the primary solution.
|
||||
@@ -37,7 +37,7 @@
|
||||
## Non-goals
|
||||
|
||||
- 不改变桌面端 hover 交互。
|
||||
- 不替换 `countries-admin0.min.geojson` 数据源。
|
||||
- 不引入旧低精度国界兜底;移动端中心国家能力必须复用生产 PMTiles/MVT 国界源。
|
||||
- 不新增后端 API。
|
||||
- 不把国家面填充做成新的 selected country 面状 shader。
|
||||
- 不为移动端增加永久准星 UI,除非后续产品明确需要视觉准星。
|
||||
@@ -249,4 +249,3 @@ mobileCenterHoverGlowOpacity
|
||||
3. 性能保护:加入节流、经纬度阈值和禁用态清理。
|
||||
4. 验证:本地构建通过,移动端 viewport 手动检查通过。
|
||||
5. 调优:根据截图或真机体验微调阻塞条件和节流阈值。
|
||||
|
||||
|
||||
131
docs/plans/earthfeed-coordinate-queue-plan.md
Normal file
131
docs/plans/earthfeed-coordinate-queue-plan.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# EarthFeed 新闻坐标与异步精修计划
|
||||
|
||||
## 目标
|
||||
|
||||
EarthFeed 的每条新闻都直接携带巡航可用坐标。初始响应使用新闻所属大区的锚点坐标,后台通过消息队列异步推理更精确的目标地址,完成后用实时补丁替换原新闻坐标,实现前端无感更新。
|
||||
|
||||
## 返回结构
|
||||
|
||||
`GET /api/v1/news/earth-feed` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"generated_at": "2026-05-15T03:16:43Z",
|
||||
"focus": {
|
||||
"lat": null,
|
||||
"lon": null,
|
||||
"region": "global",
|
||||
"label": "全球焦点",
|
||||
"accent": "#d6e6ff"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"id": "bbc-world",
|
||||
"name": "BBC World",
|
||||
"region": "global",
|
||||
"homepage_url": "https://www.bbc.com/news/world"
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"id": "bbc-world:af01519ba7dd",
|
||||
"title": "Flattery and fanfare as Trump welcomed to China - but thorny issues remain",
|
||||
"summary": "The leaders of the world's two superpowers were all smiles...",
|
||||
"url": "https://www.bbc.com/news/articles/cdxpypg9dgeo",
|
||||
"source": "BBC World",
|
||||
"feed_name": "BBC World",
|
||||
"region": "global",
|
||||
"homepage_url": "https://www.bbc.com/news/world",
|
||||
"published_at": "2026-05-14T13:02:13Z",
|
||||
"latitude": 39.9057136,
|
||||
"longitude": 116.3912972,
|
||||
"location_label": "北京市, 中国",
|
||||
"location_source": "headline_location_hint",
|
||||
"verified": true,
|
||||
"location_meta": {
|
||||
"resolution_stage": "headline_location_hint",
|
||||
"ai_attempted": false,
|
||||
"ai_status": "skipped_text_hint",
|
||||
"ai_error": null,
|
||||
"debug_note": "text hint matched 北京市, 中国",
|
||||
"target": {
|
||||
"latitude": 39.9057136,
|
||||
"longitude": 116.3912972,
|
||||
"label": "北京市, 中国",
|
||||
"source": "headline_location_hint",
|
||||
"confidence": 0.78,
|
||||
"country": "中国",
|
||||
"city": "Beijing"
|
||||
},
|
||||
"anchor": {
|
||||
"region": "global",
|
||||
"label": "全球",
|
||||
"latitude": 20.0,
|
||||
"longitude": 0.0
|
||||
}
|
||||
},
|
||||
"is_focus_match": true
|
||||
}
|
||||
],
|
||||
"errors": [],
|
||||
"stale": false
|
||||
}
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
- `latitude` / `longitude`:前端巡航唯一读取的坐标。
|
||||
- `location_label`:当前坐标展示名。
|
||||
- `location_source`:`region_anchor`、`headline_location_hint`、`headline_country_hint`、`ai_inferred_target` 等。
|
||||
- `verified`:`false` 表示仍是大区锚点;`true` 表示已经由标题规则、国家规则或 AI 得到目标地址。
|
||||
- `location_meta`:调试、诊断、AI 状态、目标地址和锚点详情都放这里,不再展开成 `t_*` 主字段。
|
||||
|
||||
## 后台队列
|
||||
|
||||
当前使用 Redis Streams:
|
||||
|
||||
- stream:`earth_news:target_location:jobs`
|
||||
- consumer group:`earth_news_target_location`
|
||||
- result cache:`earth_news:target_location:result:{item_id}`
|
||||
- dedupe key:`earth_news:target_location:queued:{item_id}`
|
||||
|
||||
请求流程:
|
||||
|
||||
1. RSS 拉取并排序。
|
||||
2. 每条新闻先生成大区锚点坐标,`verified=false`。
|
||||
3. 若 Redis 已有该新闻的精修结果,则合并结果返回。
|
||||
4. 若没有精修结果,则把新闻 job 入队,接口立即返回。
|
||||
|
||||
Worker 流程:
|
||||
|
||||
1. 从队列消费新闻 job。
|
||||
2. 先跑标题/国家规则,再视情况调用 AI。
|
||||
3. 写入 result cache。
|
||||
4. 广播 WebSocket 补丁:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "data_frame",
|
||||
"channel": "earth_news",
|
||||
"timestamp": "2026-05-15T03:17:00Z",
|
||||
"payload": {
|
||||
"item_id": "bbc-world:af01519ba7dd",
|
||||
"patch": {
|
||||
"latitude": 39.9057136,
|
||||
"longitude": 116.3912972,
|
||||
"location_label": "北京市, 中国",
|
||||
"location_source": "headline_location_hint",
|
||||
"verified": true,
|
||||
"location_meta": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 可迁移性
|
||||
|
||||
业务代码只调用队列接口,不直接依赖 Redis Streams 细节。以后迁移 Kafka 时新增 Kafka adapter,保持 job payload、result patch 和 worker 推理逻辑不变。
|
||||
|
||||
## 前端规则
|
||||
|
||||
新闻面板和巡航都只读取新闻项内的 `latitude` / `longitude`。实时补丁到达后按 `item_id` 合并到现有 `payload.items`,重新渲染并触发 `earth:news-payload-updated`,巡航下一轮自然使用精修坐标。
|
||||
@@ -95,9 +95,24 @@ The AI settings page uses:
|
||||
- `POST /api/v1/settings/integrations/ai-provider/connect`
|
||||
- `GET /api/v1/settings/integrations/ai-provider/secrets`
|
||||
- `GET /api/v1/settings/integrations/ai-provider/presets`
|
||||
- `GET /api/v1/settings/ai-prompts`
|
||||
- `PUT /api/v1/settings/ai-prompts/{task_key}`
|
||||
- `POST /api/v1/settings/ai-prompts/{task_key}/reset`
|
||||
|
||||
These endpoints require an authenticated user. The `secrets` endpoint is only used when the settings page reveals a key or token; hiding the field restores the masked preview.
|
||||
|
||||
The `ai-prompts` endpoints back the Prompts tab in AI settings. Shipped defaults come from versioned backend resources, while business code references stable task keys. The API stores only operator overrides. Resetting a prompt removes the override and falls back to the current shipped default.
|
||||
|
||||
### Prompt Boundary
|
||||
|
||||
`aiprovider` is a pure model adapter and does not inject a global business system prompt. News localization, alert briefing, BGP briefing, location factcheck, datasource mapping, and credential guide generation each resolve their own effective prompt by task key. Alert-analysis system prompts are only sent by alert-related tasks and do not leak into other LLM calls.
|
||||
|
||||
### Agent And Tool Boundary
|
||||
|
||||
Agent workflows belong in the `backend`, not in `aiprovider`. Future Earth LLM commands, situational awareness, multi-role simulation, WebSearch, database queries, evidence storage, and configuration proposal application should be orchestrated by the backend Agent Runtime. `aiprovider` should receive model-ready requests from the backend and return normalized model responses.
|
||||
|
||||
If a provider supports native tool calling, `aiprovider` may pass through protocol fields and normalize response blocks, but tool whitelists, argument validation, permission policy, run records, and write approvals must stay in the backend. When a provider does not support native tools, the backend uses JSON tool-call fallback; business tools should not move into `aiprovider` for a provider-specific shortcut.
|
||||
|
||||
### AI provider internal API
|
||||
|
||||
Internal-only endpoints:
|
||||
@@ -287,7 +302,6 @@ SERVICE_VERSION=0.1.0
|
||||
AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
AI_TIMEOUT_SECONDS=60
|
||||
AI_HTTP_RETRY_ATTEMPTS=2
|
||||
AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。
|
||||
```
|
||||
|
||||
Optional provider-specific keys:
|
||||
|
||||
@@ -89,6 +89,8 @@ async def run(self, db):
|
||||
|
||||
AIS vessel collectors use a different persistence path from regular `CollectedData` collectors. BarentsWatch, AISStream, and custom `vessel_ais` sources write into the AIS raw observation layer first, then the aggregation service merges those observations into the GeoJSON and detail payloads used by the Earth vessel layer. This preserves source, transport, field conflicts, and observation time instead of letting one realtime source overwrite the final display table.
|
||||
|
||||
Earth boundaries are no longer data collectors. They are Earth static rendering assets: the Earth Assets settings panel owns source configuration, and `/api/v1/earth/boundaries/*` builds `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`. When no high-precision PMTiles artifact is available locally, the frontend uses the bundled low-precision GeoJSON fallback and does not write boundary records to `CollectedData`.
|
||||
|
||||
TOP500 and Epoch AI compute sources do not always provide usable coordinates. The unified Earth compute-center endpoint uses only valid source-provided coordinates or `compute_center_locations` dimension-table coordinates during the main map startup path; records without coordinates are returned as `unresolved` instead of being rendered from a local registry, country centroid, or guessed city. When users manually collect candidates, the backend queries ROR and Nominatim/OpenStreetMap from source fields; accepted candidates are saved into `compute_center_locations` and rendered from that table on the next layer refresh.
|
||||
|
||||
## IV. Data Format (stored in CollectedData table)
|
||||
@@ -219,7 +221,8 @@ backend/app/services/collectors/
|
||||
├── peeringdb.py # PeeringDB collector
|
||||
├── telegeraphy.py # TeleGeography submarine cable collector
|
||||
├── vessel_ais.py # BarentsWatch AIS vessel collector
|
||||
└── aisstream.py # AISStream WebSocket vessel collector
|
||||
├── aisstream.py # AISStream WebSocket vessel collector
|
||||
└── earth_boundaries.py # Earth boundary source verification and static tile artifact collector
|
||||
|
||||
backend/app/services/
|
||||
├── custom_datasource_runtime.py # Custom REST / WebSocket mapping runtime
|
||||
@@ -297,7 +300,7 @@ State semantics:
|
||||
- `reconnecting`: upstream or network interruption; the collector records `AISSourceHealth` and waits before reconnecting.
|
||||
- `stopped` / `cancelled`: stopped by a test limit or user action.
|
||||
|
||||
AISStream connectivity validation reads the saved collector configuration, environment variables, and `AISSTREAM_API_KEY` in `~/.zshrc` through `datasource_connectivity.py`. For actual collection, the most reliable path is saving the API key in `Settings -> Collector Settings -> AISStream Vessels`; if the key only lives in `~/.zshrc`, confirm that the backend process inherited it.
|
||||
AISStream connectivity validation reads the saved collector configuration, environment variables, and `AISSTREAM_API_KEY` in `~/.zshrc` through `datasource_connectivity.py`. For actual collection, the most reliable path is saving the API key in `Collection Management -> Collectors -> AISStream Vessels`; if the key only lives in `~/.zshrc`, confirm that the backend process inherited it.
|
||||
|
||||
The console manages AISStream from `/datasources -> Realtime Streams`, not from the normal finite collection progress bar. The realtime stream API aggregates runtime state, health, configuration preview, and raw observation counters:
|
||||
|
||||
@@ -365,9 +368,9 @@ GET /api/v1/layers/bgp/collectors?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&l
|
||||
|
||||
`/api/v1/data-products/*` is for aggregate panels and keeps a global statistics scope independent of the map bbox. `/api/v1/layers/*` is for map rendering, requires `bbox` and `zoom`, defaults to `limit=1000`, and caps `limit` at `5000`; low zoom falls back to a smaller response cap and reports `degraded`, `truncated`, `limit_clamped`, and `stats_scope=viewport` in `diagnostics`. Non-vessel layers currently reuse the existing GeoJSON converters before the guard layer; future product-specific queries can push bbox filtering deeper.
|
||||
|
||||
## X. Collector Settings And Connectivity Validation
|
||||
## X. Collectors And Connectivity Validation
|
||||
|
||||
The console "Collector Settings" page owns endpoint, headers, timeouts, retries, and credentials for all built-in collectors. Connectivity is derived by the backend checksum rather than by frontend button styling:
|
||||
The console "Collectors" page owns endpoint, headers, timeouts, retries, and credentials for all built-in collectors. Connectivity is derived by the backend checksum rather than by frontend button styling:
|
||||
|
||||
- endpoint
|
||||
- auth type
|
||||
@@ -388,7 +391,7 @@ POST /api/v1/settings/credential-guides/{provider}/generate
|
||||
POST /api/v1/settings/credential-guides/{provider}/reset
|
||||
```
|
||||
|
||||
See [Collector Settings and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md) for the full flow.
|
||||
See [Collectors and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md) for the full flow.
|
||||
|
||||
## XI. Data Usage
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ The console now separates the "data source catalog" from "collector configuratio
|
||||
- Lists all data sources, including built-in and custom sources.
|
||||
- Clicking a name only opens an information drawer.
|
||||
- Focuses on status, manual collection, and running collection tasks.
|
||||
- `/settings?tab=collector_credentials`
|
||||
- Displays as "Collector Settings".
|
||||
- `/collection-management?tab=collector_credentials`
|
||||
- Displays as "Collectors".
|
||||
- Owns endpoint, headers, base parameters, and credentials.
|
||||
- Every collector exposes a connection button for health checks.
|
||||
|
||||
This reduces first-use confusion: API endpoints, headers, credentials, and custom source configuration all belong to collector settings instead of being scattered across the data source list and system settings.
|
||||
This reduces first-use confusion: API endpoints, headers, credentials, and custom source configuration all belong to Collectors instead of being scattered across the data source list and system settings.
|
||||
|
||||
## User-Facing Rules
|
||||
|
||||
@@ -53,7 +53,7 @@ Current behavior:
|
||||
|
||||
`data-source-bulk-toolbar__running-pill` is the styling entry point for the "Collecting" pill. It is aligned with other status tags, while hover treatment, arrow affordance, and blue outline indicate interactivity.
|
||||
|
||||
### Collector Settings
|
||||
### Collectors
|
||||
|
||||
File:
|
||||
|
||||
@@ -61,7 +61,7 @@ File:
|
||||
|
||||
Current behavior:
|
||||
|
||||
- The `collector_credentials` tab is displayed as "Collector Settings".
|
||||
- The `collector_credentials` tab is displayed as "Collectors" under `/collection-management`.
|
||||
- A select lists built-in collectors and supports maintaining custom supplemental sources that merge into built-in data.
|
||||
- The only button beside the select is a plug icon for health checks.
|
||||
- Status tags below the select show:
|
||||
@@ -307,6 +307,8 @@ Files:
|
||||
|
||||
Custom sources are supplemental inputs for existing target schemas, not isolated data islands. The most complete target today is `vessel_ais`: a custom REST or WebSocket source is mapped deterministically, written into AIS raw observations, and then pushed to Earth through the `vessels` WebSocket channel.
|
||||
|
||||
Earth high-precision boundaries no longer use custom-source target schemas. Boundaries are Earth static assets: the Earth Assets settings panel saves local source configuration and triggers PMTiles builds without writing records to `CollectedData`.
|
||||
|
||||
### Configuration Semantics
|
||||
|
||||
Important fields:
|
||||
@@ -316,7 +318,7 @@ Important fields:
|
||||
- `auth_type`: `none`, `bearer`, `api_key`, or `basic`.
|
||||
- `headers`: static request headers.
|
||||
- `auth_config`: token, API key, or basic username/password; API keys can be sent by header or query.
|
||||
- `config.target_schema`: for example `vessel_ais`.
|
||||
- `config.target_schema`: for example `vessel_ais`, `geo_points`, or `generic_records`.
|
||||
- `config.delivery_mode`: REST defaults to `polling`; WebSocket defaults to `realtime_stream`.
|
||||
- `config.merge_target_source`: records which built-in source this custom source supplements, such as `barentswatch_vessels`.
|
||||
|
||||
|
||||
@@ -71,6 +71,10 @@ Responsibilities:
|
||||
|
||||
This is currently the most critical UI control entry point for the Earth frontend.
|
||||
|
||||
Earth settings are now grouped by `data-settings-tab` and `data-settings-tab-panel`. Desktop and mobile share the same category semantics: Runtime, Display, Panels, Motion, Shortcuts, and System. When adding a setting, first choose its category, then add the DOM, persistence field, and restore logic; do not keep growing one long undifferentiated panel.
|
||||
|
||||
Shortcut configuration is a device-local preference owned by `controls.js`: read, capture, enable/disable, and reset all stay in the Earth frontend. It should not be written to backend user settings and should not affect other browsers. New shortcuts must provide a default key, display label, disabled/enabled state, and reset path instead of being hard-coded only in a keydown handler.
|
||||
|
||||
### 4. UI and Status Messages
|
||||
|
||||
- [ui.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/ui.js)
|
||||
@@ -122,6 +126,9 @@ Responsibilities:
|
||||
- Globe sphere, cloud layer, atmosphere
|
||||
- Real terrain mesh
|
||||
- Terrain tile fetch, decode, displacement, and shading
|
||||
- Whole-globe land/ocean and border base overlays
|
||||
|
||||
The Earth surface is a stack of near-concentric shells, not a single mesh. The base sphere and HD texture overlay in `earth.js`, plus the land/ocean base in `country-boundaries.js`, need explicit radius separation. At far zoom, GPU depth precision drops; neighboring shells that are too close can z-fight and show black flicker blocks or snow. The current stable spacing is `landAltitudeOffset = 0.32` and `textureOverlayAltitudeOffset = 0.48`. When adding or adjusting whole-globe surface overlays, update [Earth Render Layer Order](/home/ray/dev/linkong/planet/docs/technical/en/earth-render-layer-order.md) and verify at 50% zoom.
|
||||
|
||||
### 7. Layer Modules
|
||||
|
||||
@@ -142,6 +149,8 @@ Each module is responsible for its own:
|
||||
|
||||
`tv.js` owns the live / aggregation-news tabs inside `media-panel`. Toolbar open and tab-switch actions write back through `earth:tv-visibility-change` and `earth:tv-tab-change`: panel visibility remains viewport-scoped at `views.<scope>.panelVisibility.media-panel`, while the active tab is stored at `shared.mediaPanelActiveTab`. Refreshing the page therefore restores the user's last live/news state. Temporary hides from `closeTransientMobileOverlays()` carry `persist:false` and do not overwrite the preference.
|
||||
|
||||
`brand.js` manages Earth HUD brand resources. Static assets provide the default brand; runtime overrides come from `/api/v1/earth/brand`, and uploaded images are served from `/earth-brand-assets/...`. The frontend must treat logo/title images and text fallback separately: if an image fails, show the text title; if text fields are empty, rely on backend defaults so the HUD brand area never renders blank. The console Earth Content page owns saving and resetting brand configuration; the Earth frontend only consumes it.
|
||||
|
||||
The compute-center layer row has a notification badge for GeoJSON `unresolved` records. The badge means "no trustworthy coordinates, cannot render on the globe"; it is different from the `?` marker drawn on already positioned but unconfirmed compute centers. Clicking the badge opens a fixed info card beside the layer panel. Row-level `采集` fetches candidates only. Header-level `一键采用` processes the queue top-to-bottom, saves the highest-confidence valid candidate, removes successful rows, renumbers the list, and dispatches `earth:compute-center-unresolved-count-change` so the badge updates immediately. When the batch ends, `earth:compute-center-location-saved` refreshes the real layer.
|
||||
|
||||
Location candidate state in the details card is cached in [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) by `entityType:entityId`. If the user closes the details card or unresolved queue and reopens the same compute center / BGP collector, previously collected candidates and status text are restored. Header-level `一键采用` prefers cached candidates, avoiding repeated online geocoding or LLM factcheck calls. After a location is saved, that entity's candidate list is cleared to a "refreshing layer" status so stale candidates do not keep misleading the user.
|
||||
@@ -213,6 +222,8 @@ The real satellite altitude preference is persisted by `controls.js`, while the
|
||||
|
||||
SGP4 propagation returns an inertial-frame position, so it must not be drawn directly as Earth-fixed longitude / latitude. `satellites.js` uses `gstime` to convert ECI/TEME positions to ECF, then maps that result into the same Three.js axes as `latLonToVector3()`. Satellite dots and short trails use Earth-fixed coordinates for each sample time, representing the object's current position relative to the globe surface. The locked predicted orbit uses the `gstime` from the lock moment for the whole future orbit, projecting the inertial orbit plane onto the current globe pose; that keeps the line closed and keeps the visual orbit inclination aligned with the details card. Fallback predicted orbits must also use a real RAAN + inclination orbital-plane formula, not treat inclination as a constant latitude.
|
||||
|
||||
Boundary precision is stored separately by `country-boundaries.js` under `planet.earth.boundaries.highPrecisionEnabled`. When high precision is off, Earth keeps using the bundled low-precision `countries-admin0.min.geojson` fallback even if high-precision manifest/PMTiles files exist locally. When high precision is on but the artifact is missing, the Earth toolbar settings call `/api/v1/earth/boundaries/build` and poll progress. After success, `reloadCountryBoundaries()` hot-swaps the boundary layer without refreshing the page. Boundary hover is independent from interactable hover: a country polygon remains highlighted whenever the surface coordinate is inside it, while the tooltip can still prioritize a satellite, vessel, BGP marker, or other interactable.
|
||||
|
||||
## Current Terrain Pipeline
|
||||
|
||||
1. `terrain.js` creates a sphere geometry with enough segments
|
||||
|
||||
@@ -23,7 +23,7 @@ This document records the material, color, opacity, line width, radius offset, a
|
||||
| Earth base specular | `EARTH_MATERIAL_CONFIG.specular` | `0x1a2d45` | `MeshPhongMaterial.specular` |
|
||||
| Earth base shininess | `EARTH_MATERIAL_CONFIG.shininess` | `12` | `MeshPhongMaterial.shininess` |
|
||||
| Earth base opacity | `EARTH_MATERIAL_CONFIG.opacity` | `1` | `MeshPhongMaterial.opacity` |
|
||||
| HD texture radius offset | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.1` | Standalone HD texture sphere radius |
|
||||
| HD texture radius offset | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.48` | Standalone HD texture sphere radius; must keep enough depth separation from the land/ocean base and Earth base sphere to avoid far-zoom z-fighting |
|
||||
| HD texture opacity | `EARTH_MATERIAL_CONFIG.textureOverlayOpacity` | `0.88` | HD texture `MeshPhongMaterial.opacity` |
|
||||
| HD texture renderOrder | `EARTH_MATERIAL_CONFIG.textureOverlayRenderOrder` | `0.96` | `_earthTextureOverlay.renderOrder` |
|
||||
| HD texture specular | `EARTH_MATERIAL_CONFIG.textureOverlaySpecular` | `0x05080d` | Reduces specular highlight in direct-light areas to avoid blown-out texture |
|
||||
@@ -78,11 +78,19 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor
|
||||
|
||||
| Name | Variable | Current Value | Location / Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Country border data path | `COUNTRY_BOUNDARY_CONFIG.dataPath` | `"/earth/data/countries-admin0.min.geojson"` | GeoJSON input |
|
||||
| Boundary tile manifest | `COUNTRY_BOUNDARY_CONFIG.tileManifestPath` | `"/earth/data/boundaries/v1/manifest.json"` | High-precision PMTiles manifest; missing manifest uses the low-precision fallback |
|
||||
| Boundary tile provider | `COUNTRY_BOUNDARY_CONFIG.tileProvider` | `"auto"` | Prefer high-precision PMTiles/MVT, then fall back to legacy GeoJSON |
|
||||
| PMTiles artifact path | `COUNTRY_BOUNDARY_CONFIG.pmtilesPath` | `"/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"` | Production single-file PMTiles/MVT artifact |
|
||||
| Low-precision fallback | `COUNTRY_BOUNDARY_CONFIG.legacyFallbackPath` | `"/earth/data/countries-admin0.min.geojson"` | Default land/ocean base and hover data when no high-precision boundary asset has been built locally |
|
||||
| MVT layer names | `COUNTRY_BOUNDARY_CONFIG.mvtLayerNames` | `boundary_admin0 / boundary_disputed_internal / coastline / claim_line` | Fixed layer names decoded by the PMTiles provider |
|
||||
| Boundary tile base path | `COUNTRY_BOUNDARY_CONFIG.tileBasePath` | `"/earth/data/boundaries/v1/"` | PMTiles manifest base path |
|
||||
| Boundary tile zoom thresholds | `COUNTRY_BOUNDARY_CONFIG.tileZoomThresholds` | `1.6 -> z5`, `2.8 -> z6`, `3.4 -> z7`, `4.0 -> z8`, `4.6 -> z9`, `5.2 -> z10` | Production PMTiles zoom selection |
|
||||
| Boundary tile cache limit | `COUNTRY_BOUNDARY_CONFIG.tileCacheLimit` | `150` | Frontend LRU cache entries for loaded tile geometries |
|
||||
| Boundary tile debounce | `COUNTRY_BOUNDARY_CONFIG.tileDebounceMs` | `180` | View-change debounce before requesting visible tiles |
|
||||
| Ocean fill color | local `OCEAN_HEX` | `0x010609` | Land/ocean base canvas background |
|
||||
| Land fill color | `COUNTRY_BOUNDARY_CONFIG.landColor` | `0x080f1b` | Land/ocean base canvas land |
|
||||
| Land/ocean base opacity | `COUNTRY_BOUNDARY_CONFIG.landOpacity` | `1.0` | `MeshBasicMaterial.opacity` |
|
||||
| Land/ocean base radius offset | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.08` | `country-land-ocean` radius |
|
||||
| Land/ocean base radius offset | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.32` | `country-land-ocean` radius; separated from the Earth base sphere to avoid snow / black block flicker at 50% zoom |
|
||||
| Land/ocean base renderOrder | `COUNTRY_BOUNDARY_CONFIG.landRenderOrder` | `0.86` | `country-land-ocean.renderOrder` |
|
||||
| Land/ocean mask size | `landMaskWidth / landMaskHeight` | `2048 / 1024` | Canvas / DataTexture size |
|
||||
| Country tint color | `COUNTRY_BOUNDARY_CONFIG.tintColor` | `0x0b1830` | Tint when HD texture is off |
|
||||
@@ -91,16 +99,16 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor
|
||||
| Border line color | `COUNTRY_BOUNDARY_CONFIG.lineColor` | `0x7fc7ff` | Normal border line |
|
||||
| Border line opacity | `COUNTRY_BOUNDARY_CONFIG.lineOpacity` | `0.58` | Normal border line opacity |
|
||||
| Border dimmed opacity on hover | `COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity` | `0.18` | Normal border opacity during hover |
|
||||
| Border line radius offset | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.115` | Normal border line radius; slightly above HD texture `0.10` and below terrain base `0.16` to reduce floating |
|
||||
| Border line radius offset | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.115` | Normal border line radius; line layers rely on renderOrder and independent geometry, not whole-globe shell depth spacing |
|
||||
| Border line renderOrder | `COUNTRY_BOUNDARY_CONFIG.lineRenderOrder` | `2.2` | Normal border line level |
|
||||
| Border hover color | `COUNTRY_BOUNDARY_CONFIG.hoverLineColor` | `0xff3b1f` | Neon red-orange |
|
||||
| Border hover opacity | `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity` | `1.0` | Hover line opacity |
|
||||
| Border hover radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.14` | Hover line radius; close to the surface but above normal border lines |
|
||||
| Border hover radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.115` | Hover line radius; matches the normal border geometry to avoid double-edge ghosting during highlight changes |
|
||||
| Border hover renderOrder | `COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder` | `2.3` | Hover line level |
|
||||
| Border hover glow opacity | `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity` | `0.38` | Glow line opacity |
|
||||
| Border hover glow line width | `COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth` | `3` | Glow `LineBasicMaterial.linewidth` |
|
||||
| Border hover glow level offset | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset` | `0.01` | Glow renderOrder = `2.29` |
|
||||
| Border hover glow radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0.04` | Glow radius = hover radius + 0.04 |
|
||||
| Border hover glow radius offset | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0` | Glow uses the same radius as the hover line to avoid coastline detail misalignment |
|
||||
|
||||
## Real Terrain
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ Goals:
|
||||
|
||||
## Collector Configuration
|
||||
|
||||
`news_live_streams` does not need a separate new page; it reuses Collector Settings under `/settings`:
|
||||
`news_live_streams` does not need a separate new page; it reuses Collectors under `/collection-management`:
|
||||
|
||||
- `endpoint`
|
||||
- Channel directory JSON API URL
|
||||
|
||||
@@ -17,8 +17,8 @@ Note: the layer control panel order and the registration / startup load order ar
|
||||
| -1 | Earth occluder sphere | `earth.js` | Invisible inner sphere | Writes depth buffer | Occludes objects behind the Earth. |
|
||||
| 0 | Earth base sphere | `earth.js` | `CONFIG.earthRadius` | Surface picking fallback target | Dark base; still visible when all optional map layers are off. |
|
||||
| 0.2 | Country dark tint | `country-boundaries.js` | `tintAltitudeOffset` | Raycast disabled | Used when HD texture is off. |
|
||||
| 0.86 | Land/ocean base fill | `country-boundaries.js` | `landAltitudeOffset`; ocean `#010609`, land `#080f1b` | Raycast disabled | Base map remains usable even when country borders are off. |
|
||||
| 0.96 | HD Earth texture | `earth.js` | `textureOverlayAltitudeOffset` | Surface picking target when visible | HD texture always overlays the land/ocean base fill. |
|
||||
| 0.86 | Land/ocean base fill | `country-boundaries.js` | `landAltitudeOffset = 0.32`; ocean `#010609`, land `#080f1b` | Raycast disabled | Base map remains usable even when country borders are off; radius is separated from the base sphere to avoid far-zoom z-fighting. |
|
||||
| 0.96 | HD Earth texture | `earth.js` | `textureOverlayAltitudeOffset = 0.48` | Surface picking target when visible | HD texture always overlays the land/ocean base fill; radius must stay above the land/ocean base and far enough from the base sphere. |
|
||||
| 1 | Atmospheric glow and clouds | `earth.js` | Atmosphere / cloud spheres | Not in normal object selection path | Cloud layer controlled by the "Cloud Layer" toggle. |
|
||||
| 1 | Submarine cables | `cables.js` | `CABLE_CONFIG.line.renderOrder` | Cable picking path | Preserves existing cable layer level. |
|
||||
| 1.2 | Real terrain | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` plus terrain displacement | Raycast disabled | Terrain overlays HD texture; temporarily hidden when HD texture is off, restores to prior state when re-enabled. |
|
||||
@@ -45,6 +45,17 @@ Note: the layer control panel order and the registration / startup load order ar
|
||||
| Border Lines off | Hides only interactive border lines and hover, clearing hover state; the land/ocean base fill remains as the Earth base map. |
|
||||
| Real Satellite Altitude off | Satellite dots, trails, and predicted orbits use the legacy same-sphere height; satellites with missing TLE data or failed propagation also use this fallback height. |
|
||||
|
||||
## Depth Spacing Rules
|
||||
|
||||
The Earth surface is not a single mesh. It is a stack of near-concentric shells: base sphere, land/ocean base, HD texture, terrain, clouds, atmosphere, and the occluder. Radius offsets that look harmless at close zoom can collapse into the same depth-buffer pixels at zoomed-out views such as 50%, causing z-fighting that appears as black blocks, snow, or flicker.
|
||||
|
||||
Maintenance rules:
|
||||
|
||||
- Do not reach first for hiding layers at far zoom. Check neighboring shell `altitudeOffset`, `renderOrder`, `depthTest`, and `depthWrite` first.
|
||||
- Whole-globe overlays such as the land/ocean base and HD texture must keep explicit separation from `CONFIG.earthRadius`; the current stable values are `landAltitudeOffset = 0.32` and `textureOverlayAltitudeOffset = 0.48`.
|
||||
- Any new whole-globe or near-whole-globe surface overlay must be screenshot-verified at 50% zoom and at common close zooms, with no black blocks, snow, flicker, or obvious floating.
|
||||
- If these radii change, update this document and the intent around the constants in `frontend/public/earth/js/constants.js`.
|
||||
|
||||
## Interaction Rules
|
||||
|
||||
| Interaction | Current Rule |
|
||||
|
||||
@@ -55,12 +55,12 @@ tasklist /svc /fi "PID eq 4700"
|
||||
For temporary troubleshooting, you can stop IP Helper from Administrator PowerShell:
|
||||
|
||||
```powershell
|
||||
Stop-Service iphlpsvc
|
||||
Stop-Service iphlpsvc -Force
|
||||
```
|
||||
|
||||
This may affect networking, proxying, or forwarding features. Do not disable it long-term unless you know why it is safe. If the Windows forwarding rule must stay, use a different Planet backend port.
|
||||
This may affect networking, proxying, or forwarding features. Do not disable it long-term unless you know why it is safe. `--allow-lan` now binds `3000` / `8000` / `8010` directly, so persistent portproxy is no longer required.
|
||||
|
||||
If the script prints `failed-stop-service` or `failed-stop-process`, the current shell does not have permission to clear the Windows listener. Startup stops immediately instead of launching the backend into the same port conflict.
|
||||
If the script prints `Windows 侧端口 ... 存在监听者`, or Vite reports `Port 3000 is already in use` followed by `Windows listener ... services=iphlpsvc`, an old Windows listener still owns the port. The script requests Administrator PowerShell cleanup for that port. If the automatic cleanup is canceled, inspect `netsh interface portproxy show all`, delete the matching `listenport` rule, confirm the PID and services with `netstat` / `tasklist` if no portproxy rule exists, and temporarily run `Stop-Service iphlpsvc -Force` when appropriate. After old rules are gone, rerun `./planet.sh restart --allow-lan`; LAN devices still use `3000` / `8000` / `8010`.
|
||||
|
||||
### Which startup flags change default ports?
|
||||
|
||||
@@ -87,6 +87,7 @@ Check in this order before changing firewall rules:
|
||||
# In WSL or the shell running Planet
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
curl http://localhost:8010/health
|
||||
```
|
||||
|
||||
Then verify from Windows PowerShell:
|
||||
@@ -94,6 +95,7 @@ Then verify from Windows PowerShell:
|
||||
```powershell
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
curl http://localhost:8010/health
|
||||
```
|
||||
|
||||
If both localhost checks pass but a phone or another computer cannot connect, start with LAN enabled:
|
||||
@@ -108,22 +110,28 @@ The flag must be written as `--allow-lan`. `allowlan` or `--allowlan` is not rec
|
||||
./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`.
|
||||
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 port ownership, stale `portproxy`, or firewall policy rather than Vite or `.zshrc`.
|
||||
|
||||
For traditional WSL NAT networking, configure portproxy and firewall from Administrator PowerShell:
|
||||
`./planet.sh start --allow-lan` directly exposes `3000` / `8000` / `8010` and checks port availability, stale `portproxy`, and Windows Firewall before startup. If a Windows-side listener owns a port, the script requests Administrator PowerShell cleanup. When inbound allow rules are missing, it also triggers a UAC Administrator PowerShell request to create them. If the automatic request is canceled, clean up manually:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8010
|
||||
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010
|
||||
```
|
||||
|
||||
LAN devices should use the Windows external port, for example `http://<Windows LAN IP>:3000/earth`.
|
||||
|
||||
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
|
||||
New-NetFirewallHyperVRule -Name "Planet-AIProvider-8010" -DisplayName "Planet AI Provider 8010" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 8010 -Action Allow
|
||||
```
|
||||
|
||||
Use these commands to inspect the current Hyper-V firewall state:
|
||||
@@ -286,7 +294,7 @@ If only AI Provider is unhealthy, restart just that service:
|
||||
|
||||
### Connectivity validation passes, but collection cannot read credentials. Why?
|
||||
|
||||
Connectivity validation can read saved console settings, environment variables, and some credentials from `~/.zshrc`. For actual collection, prefer saving credentials in Settings -> Collector Settings, especially for AISStream's long-lived WebSocket collector.
|
||||
Connectivity validation can read saved console settings, environment variables, and some credentials from `~/.zshrc`. For actual collection, prefer saving credentials in Collection Management -> Collectors, especially for AISStream's long-lived WebSocket collector.
|
||||
|
||||
If `AISSTREAM_API_KEY` only lives in `~/.zshrc`, confirm the backend process actually inherited it. Otherwise validation may pass while the collector runtime has no key.
|
||||
|
||||
@@ -300,7 +308,7 @@ export BARENTSWATCH_CLIENT_ID="..."
|
||||
export BARENTSWATCH_CLIENT_SECRET="..."
|
||||
```
|
||||
|
||||
For stable operation, save credentials in Collector Settings so connectivity validation, collection jobs, and Earth realtime aggregation use the same configuration.
|
||||
For stable operation, save credentials in Collectors so connectivity validation, collection jobs, and Earth realtime aggregation use the same configuration.
|
||||
|
||||
## Docs / Permissions
|
||||
|
||||
@@ -315,12 +323,29 @@ Docs visibility is controlled by Gatekeeper groups:
|
||||
|
||||
## Earth Common Tasks
|
||||
|
||||
### Why does Earth say the boundary endpoint is not configured, or only show low precision boundaries?
|
||||
|
||||
Country boundaries have moved out of the collector system. They are no longer generated by datasource collection tasks. The low-precision boundary file is bundled with the frontend and is the expected fallback when no local high-precision PMTiles artifact exists.
|
||||
|
||||
There are two high-precision entry points:
|
||||
|
||||
- Earth page settings gear -> Boundary Precision: switching to High Precision starts the first background download/build, shows percentage progress, and applies the result automatically.
|
||||
- Console `Operations and Configuration -> Earth Content -> Boundary Precision`: use this to inspect provider, manifest, PMTiles, fallback state, edit source JSON, or rebuild manually.
|
||||
|
||||
If the UI says the update source is incomplete, save the source configuration from `Earth Content -> Boundary Precision`. The private local config is written to `config/earth-boundary-sources.local.json`; do not commit it. Falling back to low precision is normal when no high-precision artifact has been built.
|
||||
|
||||
### Why did collecting a location candidate not write anything?
|
||||
|
||||
Collecting and saving are two separate actions. Candidates can be previewed on Earth first. A candidate is written only after clicking Save or using the unresolved list's one-click adopt flow.
|
||||
|
||||
Compute-center saves write to `compute_center_locations` and refresh the layer. Records with no candidate stay in the unresolved list; Planet does not fabricate a location from a country center or hard-coded hint.
|
||||
|
||||
### Why did the Earth logo or title not return to the default after I edited it?
|
||||
|
||||
Earth brand assets are managed from `Operations and Configuration -> Earth Content -> Brand Assets`. Uploaded images are stored as Earth brand asset URLs. If text fields such as title or ARIA label are cleared, Planet falls back to default text so the HUD never renders an empty brand.
|
||||
|
||||
Use `Reset Brand Assets` to restore the shipped logo, title image, and copy. Refreshing Earth does not delete the saved runtime brand configuration.
|
||||
|
||||
### Why are satellites no longer on one sphere?
|
||||
|
||||
Earth enables "Real Satellite Altitude" by default. Satellite positions still come from TLE/SGP4, but altitude is compressed for display: LEO satellites stay close to the globe, while higher-orbit satellites render farther out without leaving the normal view. The maximum display offset is `25`, about one quarter of the current globe radius; this is a readability compromise that separates GEO / MEO / LEO without drawing real kilometers to scale. This setting also affects satellite trails and the predicted orbit shown after locking a satellite.
|
||||
|
||||
@@ -33,6 +33,8 @@ Current admin-related routes:
|
||||
- `/alerts/situational`
|
||||
- `/bgp`
|
||||
- `/ai`
|
||||
- `/earth-content`
|
||||
- `/collection-management`
|
||||
- `/settings`
|
||||
|
||||
`/earth` is a standalone display page and is not part of the console shell.
|
||||
@@ -277,6 +279,42 @@ Constraints:
|
||||
- Do not let tables blow out the full page
|
||||
- New table areas should reuse `TableScrollRegion` / `ScrollbarOverlay`
|
||||
|
||||
### Datasource Directory Page
|
||||
|
||||
[DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) is the datasource directory and collection operation page. It should not grow back into a configuration editor.
|
||||
|
||||
Current page boundary:
|
||||
|
||||
- Built-in and custom sources are merged as `UnifiedDataSource`.
|
||||
- The list shows type, state, last run, collection progress, and actions.
|
||||
- Clicking a name opens a read-only drawer.
|
||||
- Endpoint, headers, and config are displayed here, not edited.
|
||||
- Credential-bearing collectors point users to `Collection Management -> Collectors`.
|
||||
|
||||
Keep this boundary: do not put custom datasource editing, built-in endpoint overrides, or credential forms back into `/datasources`. Those configuration entry points live at `/collection-management?tab=collector_credentials`.
|
||||
|
||||
### Collectors Page
|
||||
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) has three route modes: `/settings` for System Settings, `/earth-content` for Earth Content, and `/collection-management` for Collection Management. The `collector_credentials` tab is shown as `Collectors` under `/collection-management`.
|
||||
|
||||
Current boundary:
|
||||
|
||||
- The dropdown selects built-in collectors.
|
||||
- The plug icon beside the dropdown runs the health check.
|
||||
- Credential-bearing collectors place credential forms above base config.
|
||||
- Free collectors show endpoint, default endpoint, headers, timeout, and retry.
|
||||
- BarentsWatch AIS keeps its dedicated credential form.
|
||||
|
||||
### Earth Content Page
|
||||
|
||||
`/earth-content` reuses the same single-screen tab container from [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx), but its ownership is separate from System Settings:
|
||||
|
||||
- `TV Livestream` owns the Earth media-panel source configuration.
|
||||
- `Boundary Precision` owns the Earth static boundary asset state: provider, low-precision fallback, high-precision manifest/PMTiles, source JSON, and build action.
|
||||
- `Base Map`, `Layer Resources`, `3D Assets`, and `News Anchor Strategy` are placeholders only. They show module status and do not invent fake APIs or fake data.
|
||||
|
||||
Do not add Earth experience resources or collection-lifecycle tabs back into `/settings`; collection belongs to `/collection-management`, and Earth display resources belong to `/earth-content`.
|
||||
|
||||
### 3. Complex Workspace Pages
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -76,15 +76,17 @@ The console at `http://localhost:3000/admin` is built with React + Ant Design. T
|
||||
| BGP Alerts | `/alerts/bgp` | BGP-related alerts |
|
||||
| Situational Alerts | `/alerts/situational` | Situational analysis alerts |
|
||||
| AI | `/ai` | Model providers, tools, testbench |
|
||||
| Earth Content | `/earth-content` | TV livestreams, boundary precision, base-map and layer-resource entry points |
|
||||
| Collection Management | `/collection-management` | Collectors, scheduling, collection history entry points |
|
||||
| Logs | `/logs` | Usually visible only to super admin |
|
||||
| Users | `/users` | Create/delete users, change roles/groups |
|
||||
| Settings | `/settings` | System, SMTP, TV, collectors |
|
||||
| System Settings | `/settings` | Display, notification, security, SMTP |
|
||||
|
||||
Menu items hide automatically when you lack permission. If a menu is missing, check your role and Gatekeeper groups.
|
||||
|
||||
## Configure Data Collectors
|
||||
|
||||
`/settings?tab=collector_credentials` is the "Collector Settings" page. It manages connection configuration for every collector, not just credentials.
|
||||
`/collection-management?tab=collector_credentials` is the "Collectors" page. It manages connection configuration for every collector, not just credentials. Legacy `/settings?tab=collector_credentials` redirects here; the datasource directory remains at `/datasources`.
|
||||
|
||||
Steps:
|
||||
|
||||
@@ -125,7 +127,7 @@ The default guide follows the BarentsWatch official tutorial and reminds you to
|
||||
|
||||
Steps:
|
||||
|
||||
1. Open `/settings?tab=collector_credentials` and select `AISStream Realtime Vessels : aisstream_vessels`
|
||||
1. Open `/collection-management?tab=collector_credentials` and select `AISStream Realtime Vessels : aisstream_vessels`
|
||||
2. Fill the AISStream API Key
|
||||
3. Keep the default endpoint `wss://stream.aisstream.io/v0/stream`
|
||||
4. Click the plug icon to test; confirm it reports `Reachable`
|
||||
@@ -139,10 +141,11 @@ Steps:
|
||||
|
||||
## Configure AI Credentials
|
||||
|
||||
`/ai?tab=providers` is the AI management entry. Two key sub-tabs:
|
||||
`/ai?tab=providers` is the AI management entry. Three key sub-tabs:
|
||||
|
||||
- `Model Providers`: default LLM provider, model, base URL, API key, local `aiprovider` proxy, connection test
|
||||
- `Tools`: a dropdown for specific tools — currently WebSearch and OCR
|
||||
- `Prompts`: a task dropdown for news localization, alert analysis, BGP briefs, and other LLM tasks. Operators can edit the prompt or reset it to the default
|
||||
|
||||
### Model Providers
|
||||
|
||||
@@ -163,6 +166,10 @@ The plug icon at the end of the Base URL input runs a connection test. A passing
|
||||
- **WebSearch**: provider, API key, base URL, max results, timeout, advanced provider parameters. While disabled, all fields except the enable switch are greyed out
|
||||
- **OCR**: provider, base URL, API key, model/engine, recognition languages, timeout, max file size, output format
|
||||
|
||||
### Prompts
|
||||
|
||||
After selecting a task, the page shows the effective prompt, whether it is customized, the shipped default version, and a reset button. Saving affects only that task. Reset restores the default prompt from the current release package. Business facts, context, and output schemas are still assembled by the backend for each task.
|
||||
|
||||
The legacy link `/settings?tab=ai` redirects to `/ai?tab=providers`.
|
||||
|
||||
## System Settings
|
||||
@@ -173,8 +180,27 @@ The legacy link `/settings?tab=ai` redirects to `/ai?tab=providers`.
|
||||
- **Notifications**: alert email switch, recipient, critical/warning/daily summary
|
||||
- **Security**: session timeout, max login attempts, password policy
|
||||
- **SMTP Email**: outgoing email used by registration and password reset (visible to `admin` / `super_admin` only)
|
||||
- **TV Livestream**: TV source management
|
||||
- **AI / WebSearch / OCR**: see above
|
||||
|
||||
TV livestreams and boundary precision moved to `/earth-content`; collectors and scheduling moved to `/collection-management`; AI Provider / WebSearch / OCR live at `/ai`.
|
||||
|
||||
### Earth Content
|
||||
|
||||
`/earth-content` is under the console's Operations and Configuration group and owns resources used by the Earth frontend:
|
||||
|
||||
- **Brand Assets**: manages the logo, title image, title text, subtitle, and description used by the Earth HUD. Uploaded images are saved as Earth brand assets and read by the Earth page immediately.
|
||||
- **TV Livestream**: manages sources shown in the Earth media panel.
|
||||
- **Boundary Precision**: shows the current provider, low-precision fallback, high-precision PMTiles/manifest status, local source JSON, and manual build action.
|
||||
- **Base Map**, **Layer Resources**, **3D Assets**, and **News Anchor Strategy**: placeholder tabs for future configuration. They do not display fake data.
|
||||
|
||||
The Earth page settings gear also includes Boundary Precision. Switching to High Precision starts a local background download/build, like a game update package, when no high-precision asset exists yet. Progress is shown as a percentage, and the result applies automatically after success without a page reload. Switching back to Low Precision only changes the local display preference.
|
||||
|
||||
### Collection Management
|
||||
|
||||
`/collection-management` is also under Operations and Configuration and owns the collection lifecycle:
|
||||
|
||||
- **Collectors**: endpoint, headers, credentials, timeout, retry, and connection checks.
|
||||
- **Collection Scheduling**: the existing scheduling configuration.
|
||||
- **Collection History / Snapshots**: a placeholder for future collection task, snapshot, and collected-data browsing.
|
||||
|
||||
### SMTP Email Settings
|
||||
|
||||
@@ -204,7 +230,7 @@ To let a regular user read developer or operations docs, add `docs_developer` or
|
||||
|
||||
## Data Exploration
|
||||
|
||||
- `/datasources`: source directory. The `Collection Tasks` tab is for one-shot, scheduled, and finite collectors; it can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; with no selected rows, `Collect current filter` triggers the filtered scope. The `Realtime Streams` tab is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/settings -> Collector Settings`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list
|
||||
- `/datasources`: source directory. The `Collection Tasks` tab is for one-shot, scheduled, and finite collectors; it can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; with no selected rows, `Collect current filter` triggers the filtered scope. The `Realtime Streams` tab is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/collection-management -> Collectors`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list
|
||||
- `/data`: collected data table — used to verify "did data arrive", "is the freshness right", "does a source emit valid records"
|
||||
- `/bgp`: BGP detail page with list + detail + analysis; complements the BGP layer on Earth
|
||||
- `/alerts/system`, `/alerts/bgp`, `/alerts/situational`: system, BGP, and situational alerts
|
||||
@@ -255,7 +281,7 @@ Candidates preview on Earth directly. Saving a compute-center candidate writes t
|
||||
|
||||
### Settings
|
||||
|
||||
The settings panel covers: rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, hover tooltip, satellite idle breathing, real satellite altitude, track display, compact dots, day-night mode, panel toggles), motion debug mode / input source / skeleton-only, default globe size, terrain opacity, reset.
|
||||
The settings panel is grouped into Runtime, Display, Panels, Motion, Shortcuts, and System. It covers rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, hover tooltip, satellite idle breathing, real satellite altitude, track display, compact dots, day-night mode, panel toggles), motion debug mode / input source / skeleton-only, shortcut enablement and remapping, default globe size, terrain opacity, reset.
|
||||
|
||||
"Real Satellite Altitude" is enabled by default: satellite positions use a compressed display height based on TLE/SGP4 orbital altitude. LEO satellites remain close to the globe, while high-orbit satellites render farther out without leaving the normal view. The high-orbit display height is capped at about one quarter of the globe radius, so GEO / MEO objects remain visually separated from LEO without spreading trails and selection targets too far apart. Turning it off restores the legacy same-sphere satellite display. "Track Display" controls satellite trail visibility; trails are unavailable while the satellite layer is hidden.
|
||||
|
||||
@@ -263,6 +289,8 @@ The settings panel covers: rotate / cruise / motion mode, cruise modules (BGP/ne
|
||||
|
||||
These settings live in browser local storage; switching browsers or clearing site data resets them.
|
||||
|
||||
Shortcuts also live in browser local storage. Use the Shortcuts category to disable an individual shortcut, capture a new key, or restore the default binding for the current browser only.
|
||||
|
||||
### View Controls
|
||||
|
||||
| Action | Effect |
|
||||
|
||||
@@ -177,7 +177,7 @@ Frontend startup now has an additional pre-start cleanup retry layer:
|
||||
- `PORT_PRESTART_RETRIES`: defaults to 3 attempts.
|
||||
- `PORT_PRESTART_RETRY_INTERVAL`: defaults to 2 seconds.
|
||||
|
||||
`kill_port_if_requested()` first cleans listener PIDs visible in the current environment. It only checks for Windows-side listeners when the script detects WSL, no local listener PID is visible, and the port still cannot bind. In that WSL-only path it tries to stop the owning Windows service or force-stop the owning process through PowerShell. If permissions are missing, or a system service such as `iphlpsvc` refuses to stop, the script prints the Windows listener details and stops startup immediately instead of launching the service into the same port error. Non-WSL environments do not run the Windows cleanup path. At that point, use Administrator PowerShell to clear the portproxy/service ownership, or choose another port.
|
||||
`kill_port_if_requested()` first cleans listener PIDs visible in the current environment. It only checks for Windows-side listeners when the script detects WSL, no local listener PID is visible, and the port still cannot bind. In that WSL-only path it requests Administrator PowerShell to delete stale `portproxy` rules, stop services that own the port, or force-stop the owning process. If the administrator request is canceled, or a system service such as `iphlpsvc` refuses to stop, the script prints the Windows listener details and Administrator PowerShell recovery commands, then stops startup immediately instead of launching the service into the same port error. If the frontend Vite process only discovers `Port 3000 is already in use` after launch, the script prints the same Windows listener recovery commands. Non-WSL environments do not run the Windows cleanup path. `--allow-lan` now exposes `3000` / `8000` / `8010` directly and no longer starts an extra Windows forwarding process; old persistent portproxy rules should be removed.
|
||||
|
||||
## Issue 4: `restart` Behavior
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ Useful for:
|
||||
- Demoing Earth from a phone or tablet
|
||||
- Other LAN machines reaching the same dev instance
|
||||
|
||||
`--allow-lan` only makes the frontend and backend listen on `0.0.0.0`. When Planet runs in WSL, Windows can usually reach it through `localhost`, but other LAN machines hitting `http://<Windows LAN IP>:3000` still need Windows port forwarding and firewall rules.
|
||||
`--allow-lan` directly exposes the frontend, backend, and AI Provider from the development machine: frontend `3000`, backend `8000`, and AI Provider `8010`. Before startup, the script checks all three ports. If WSL/Linux cannot release a port and a Windows-side listener or stale `portproxy` rule owns it, the script requests Administrator PowerShell cleanup. When Planet runs in WSL, Windows can usually reach it through `localhost`; other LAN machines reaching the Windows LAN IP still need Windows Firewall allow rules.
|
||||
|
||||
Diagnose in this order:
|
||||
|
||||
@@ -128,19 +128,24 @@ Diagnose in this order:
|
||||
# From the shell running Planet
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
ss -ltnp | grep -E ':3000|:8000'
|
||||
curl http://localhost:8010/health
|
||||
ss -ltnp | grep -E ':3000|:8000|:8010'
|
||||
```
|
||||
|
||||
If WSL shows `0.0.0.0:3000` / `0.0.0.0:8000` but the LAN IP still fails, configure Windows from an elevated PowerShell:
|
||||
If the services are running but the LAN IP still fails, first remove stale `portproxy` rules and confirm Windows Firewall allows the ports. The script checks this automatically and requests Administrator PowerShell when needed. Manual fallback commands:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8010
|
||||
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010
|
||||
```
|
||||
|
||||
LAN devices should use the Windows external ports, for example `http://<Windows LAN IP>:3000/earth`, `http://<Windows LAN IP>:8000/health`, and `http://<Windows LAN IP>:8010/health`.
|
||||
|
||||
## AI Provider Environment and Builds
|
||||
|
||||
AI Provider runtime configuration lives in two places:
|
||||
@@ -236,6 +241,15 @@ uv sync
|
||||
uv run pytest backend/tests/test_otp_service.py
|
||||
```
|
||||
|
||||
## Earth Boundary PMTiles Operations
|
||||
|
||||
1. In the console, open `Operations and Configuration -> Earth Content -> Boundary Precision` to save boundary source configuration. The local config is written to `config/earth-boundary-sources.local.json`; do not commit it.
|
||||
2. Click "Build high precision boundaries", or switch the Earth toolbar settings gear to High Precision for the first build. The backend downloads the three source packages to `data/earth-boundary-sources/`, writes the source manifest, and invokes the PMTiles build script.
|
||||
3. The builder requires `tippecanoe` and `pmtiles` on PATH. Missing tools return a clear API error and do not write data-source collection records.
|
||||
4. A successful production build outputs `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles` and its manifest.
|
||||
5. After deployment, open Earth, enable "Border Lines", and inspect China's southeast coast, Taiwan, Hainan, the South China Sea, Zangnan, Kosovo, and Gaza for hover behavior and boundary policy.
|
||||
6. If no high-precision manifest/PMTiles exists locally, Earth uses the bundled `frontend/public/earth/data/countries-admin0.min.geojson` fallback. If high-precision assets exist but tile requests fail, troubleshoot PMTiles range requests, manifest provider, Nginx `.pmtiles` static serving, and sha256 consistency.
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [planet.sh Startup Mechanism](/home/ray/dev/linkong/planet/docs/technical/en/ops-planet-sh-startup.md)
|
||||
|
||||
@@ -11,7 +11,7 @@ Open the URL your administrator gave you, e.g. `http://planet.example.com`. A lo
|
||||
Entry points are split in two:
|
||||
|
||||
- Public: `/earth` (3D situational view), `/docs` (public documentation)
|
||||
- Login required: `/admin` (console), `/ai` (AI), `/settings` (system configuration)
|
||||
- Login required: `/admin` (console), `/ai` (AI), `/settings` (system settings), `/earth-content` (Earth content), `/collection-management` (collection management)
|
||||
|
||||
## 2. Register
|
||||
|
||||
@@ -32,7 +32,7 @@ The default role is `viewer`: you can sign in but only see public pages. For col
|
||||
|
||||
After landing on the `/admin` dashboard, here's a recommended walk-through:
|
||||
|
||||
1. `/settings?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
|
||||
1. `/collection-management?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
|
||||
2. `/ai?tab=providers`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional
|
||||
3. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Collection Tasks` for finite collectors, and `/datasources -> Realtime Streams` for AISStream / WebSocket health and counters
|
||||
4. `/alerts/system`: verify system alerts look right
|
||||
|
||||
@@ -95,9 +95,24 @@ AI 配置页使用的接口:
|
||||
- `POST /api/v1/settings/integrations/ai-provider/connect`
|
||||
- `GET /api/v1/settings/integrations/ai-provider/secrets`
|
||||
- `GET /api/v1/settings/integrations/ai-provider/presets`
|
||||
- `GET /api/v1/settings/ai-prompts`
|
||||
- `PUT /api/v1/settings/ai-prompts/{task_key}`
|
||||
- `POST /api/v1/settings/ai-prompts/{task_key}/reset`
|
||||
|
||||
这些接口都需要用户登录。`secrets` 接口只用于配置页点击显示 key/token 时取回明文,隐藏时前端恢复为脱敏预览。
|
||||
|
||||
`ai-prompts` 接口用于运维配置页的“提示词”Tab。默认提示词来自后端随发布包携带的版本化资源,业务代码只引用稳定 task key;接口只保存运维覆盖值。重置时删除覆盖值并恢复当前发布包中的缺省提示词。
|
||||
|
||||
### 提示词边界
|
||||
|
||||
`aiprovider` 是纯模型适配器,不注入通用业务 system prompt。新闻汉化、告警研判、BGP 简报、位置 factcheck、数据源映射和凭据教程等入口各自通过 task key 解析有效提示词。告警研判 prompt 只会在告警相关 task 中作为 system prompt 传入,不会污染其它 LLM 调用。
|
||||
|
||||
### Agent 与工具边界
|
||||
|
||||
Agent 工作流属于 `backend`,不属于 `aiprovider`。后续 Earth LLM 指令、态势感知、多角色模拟、WebSearch、数据库查询、证据存储和配置提案应用都应由后端 Agent Runtime 编排;`aiprovider` 只接收后端整理好的模型请求并返回规范化响应。
|
||||
|
||||
如果某个 provider 支持原生 tool calling,`aiprovider` 可以透传协议字段并规范化响应块,但工具白名单、参数校验、权限策略、运行记录和写入审批仍必须留在后端。provider 不支持原生 tools 时,后端使用 JSON tool-call fallback,不应为了某个模型厂商把业务工具下沉到 `aiprovider`。
|
||||
|
||||
### AI Provider 内部 API
|
||||
|
||||
仅供内部调用的接口:
|
||||
@@ -287,7 +302,6 @@ SERVICE_VERSION=0.1.0
|
||||
AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
AI_TIMEOUT_SECONDS=60
|
||||
AI_HTTP_RETRY_ATTEMPTS=2
|
||||
AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。
|
||||
```
|
||||
|
||||
可选 provider 专属 key:
|
||||
|
||||
@@ -90,6 +90,8 @@ async def run(self, db):
|
||||
|
||||
AIS 船只类采集器和其它 `CollectedData` 采集器的落库路径不同。BarentsWatch、AISStream 和自定义 `vessel_ais` 源都会进入 AIS 原始观测层,随后由聚合服务合并成 Earth 船只图层使用的 GeoJSON 和详情数据。这样做可以保留来源、传输方式、字段冲突和观测时间,避免某个实时源直接覆盖最终展示表。
|
||||
|
||||
Earth 国界不再属于采集器体系。它是 Earth 静态渲染资产,由控制台 `运维与配置 -> Earth 内容 -> 国界精度` 维护源配置,并由 `/api/v1/earth/boundaries/*` 构建 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`。本地没有高精 PMTiles 时,前端会使用仓库内置的低精度 GeoJSON 作为 fallback,不会向 `CollectedData` 写入国界记录。
|
||||
|
||||
TOP500 和 Epoch AI 算力数据的公开源不总是提供可用经纬度。Earth 统一算力中心接口在主地图启动链路中只使用源数据自带坐标或 `compute_center_locations` 维表坐标;缺少坐标的记录会进入 `unresolved`,不会通过本地注册表、国家质心或猜测城市自动渲染。用户手动采集候选时,后端会用源字段调用 ROR 组织注册 API 和 Nominatim/OpenStreetMap 在线搜索;候选经前端保存后写入 `compute_center_locations`,后续地图刷新再从维表渲染。
|
||||
|
||||
## 四、数据格式 (统一存储到 CollectedData 表)
|
||||
@@ -224,7 +226,7 @@ if datasource.last_status == "success":
|
||||
)
|
||||
```
|
||||
|
||||
这个记录用于控制台“采集器设置”中的连接状态判断:如果当前配置和成功采集时的 checksum 一致,就视为已连接,不要求用户再手动点击连接按钮。只有 endpoint、请求头、基础配置或凭证指纹变化时,才需要重新验证。
|
||||
这个记录用于控制台“采集管理 -> 采集器”中的连接状态判断:如果当前配置和成功采集时的 checksum 一致,就视为已连接,不要求用户再手动点击连接按钮。只有 endpoint、请求头、基础配置或凭证指纹变化时,才需要重新验证。
|
||||
|
||||
相关实现见:
|
||||
|
||||
@@ -244,7 +246,8 @@ backend/app/services/collectors/
|
||||
├── peeringdb.py # PeeringDB采集器
|
||||
├── telegeraphy.py # TeleGeography海底光缆采集器
|
||||
├── vessel_ais.py # BarentsWatch AIS 船只采集器
|
||||
└── aisstream.py # AISStream WebSocket 船只采集器
|
||||
├── aisstream.py # AISStream WebSocket 船只采集器
|
||||
└── earth_boundaries.py # Earth 国界源校验和静态瓦片 artifact 采集器
|
||||
|
||||
backend/app/services/
|
||||
├── custom_datasource_runtime.py # 自定义 REST / WebSocket 映射运行时
|
||||
@@ -264,8 +267,8 @@ backend/app/models/
|
||||
|
||||
| 采集器 | credential provider | 凭证来源 |
|
||||
| --- | --- | --- |
|
||||
| `barentswatch_vessels` | `barentswatch` | 控制台采集器设置、环境变量、`~/.zshrc` |
|
||||
| `aisstream_vessels` | `aisstream` | 控制台采集器设置、环境变量、`~/.zshrc`(连接验证可读;正式采集建议保存到采集器设置或注入后端环境) |
|
||||
| `barentswatch_vessels` | `barentswatch` | 控制台“采集管理 -> 采集器”、环境变量、`~/.zshrc` |
|
||||
| `aisstream_vessels` | `aisstream` | 控制台“采集管理 -> 采集器”、环境变量、`~/.zshrc`(连接验证可读;正式采集建议保存到“采集管理 -> 采集器”或注入后端环境) |
|
||||
| `spacetrack_tle` | `spacetrack` | 环境变量、`~/.zshrc` |
|
||||
|
||||
### BarentsWatch AIS
|
||||
@@ -324,7 +327,7 @@ AISStream 使用 `wss://stream.aisstream.io/v0/stream` WebSocket endpoint。默
|
||||
- `reconnecting`:上游断开或网络异常,采集器记录 `AISSourceHealth` 后等待重连。
|
||||
- `stopped` / `cancelled`:任务被测试上限或用户停止。
|
||||
|
||||
AISStream 连接验证会通过 `datasource_connectivity.py` 读取保存的采集器配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,最稳妥的方式是把 API Key 保存到“设置 -> 采集器设置 -> AISStream 实时船舶”;如果只放在 `~/.zshrc`,需要确认后端进程实际继承到了该环境变量。
|
||||
AISStream 连接验证会通过 `datasource_connectivity.py` 读取保存的采集器配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,最稳妥的方式是把 API Key 保存到“采集管理 -> 采集器 -> AISStream 实时船舶”;如果只放在 `~/.zshrc`,需要确认后端进程实际继承到了该环境变量。
|
||||
|
||||
控制台通过 `/datasources -> 实时流` 管理 AISStream,而不是把它放进普通有限采集任务的进度条。实时流 API 会聚合运行态、健康状态、配置摘要和 raw observation 计数:
|
||||
|
||||
@@ -394,7 +397,7 @@ GET /api/v1/layers/bgp/collectors?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&l
|
||||
|
||||
## 十、采集器设置与连接验证
|
||||
|
||||
控制台的“采集器设置”页提供所有内置采集器的 endpoint、请求头、超时、重试和凭证配置。连接验证不是只看前端按钮状态,而是由后端计算 checksum:
|
||||
控制台的“采集管理 -> 采集器”页提供所有内置采集器的 endpoint、请求头、超时、重试和凭证配置。连接验证不是只看前端按钮状态,而是由后端计算 checksum:
|
||||
|
||||
- endpoint
|
||||
- auth type
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
- 展示所有数据源,包括内置和自定义。
|
||||
- 点击名称只打开信息抽屉。
|
||||
- 负责查看状态、触发采集和查看采集中任务。
|
||||
- `/settings?tab=collector_credentials`
|
||||
- 显示为“采集器设置”。
|
||||
- `/collection-management?tab=collector_credentials`
|
||||
- 显示为“采集器”。
|
||||
- 负责 endpoint、请求头、基础参数和凭证配置。
|
||||
- 所有采集器都提供连接按钮,用于健康检查。
|
||||
|
||||
这样做是为了减少首次使用时的认知分裂:接口地址、请求头、凭证和自定义源配置都属于“采集器设置”,而不是散落在数据源列表和系统设置多个入口里。
|
||||
这样做是为了减少首次使用时的认知分裂:接口地址、请求头、凭证和自定义源配置都属于“采集器”,而不是散落在数据源列表和系统设置多个入口里。
|
||||
|
||||
## 用户侧规则
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
`data-source-bulk-toolbar__running-pill` 是“采集中”标签的样式入口。它和其他状态标签同排,但通过 hover、箭头和蓝色描边表达可交互性。
|
||||
|
||||
### 采集器设置
|
||||
### 采集器
|
||||
|
||||
文件:
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
当前行为:
|
||||
|
||||
- `collector_credentials` tab 展示为“采集器设置”。
|
||||
- `collector_credentials` tab 在 `/collection-management` 下展示为“采集器”。
|
||||
- 下拉框列出内置采集器,并支持维护合并到内置数据的自定义补充源。
|
||||
- 下拉框右侧只有一个插头图标按钮,用于健康检查。
|
||||
- 下拉框下方用状态标签展示:
|
||||
@@ -309,6 +309,8 @@ GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=
|
||||
|
||||
自定义源现在不是独立的新数据孤岛,而是作为内置数据源的补充源写入目标 schema。当前最完整的目标是 `vessel_ais`:自定义 REST 或 WebSocket 源经过确定性 mapping 后写入 AIS raw observations,再通过 `vessels` WebSocket channel 推送给 Earth。
|
||||
|
||||
Earth 高精度边界不再使用自定义源目标 schema。国界是 Earth 静态资产,由控制台 `运维与配置 -> Earth 内容 -> 国界精度` 保存本机源配置并触发 PMTiles 构建,不写入 `CollectedData`。
|
||||
|
||||
### 配置语义
|
||||
|
||||
关键字段:
|
||||
@@ -318,7 +320,7 @@ GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=
|
||||
- `auth_type`:`none`、`bearer`、`api_key`、`basic`。
|
||||
- `headers`:静态请求头。
|
||||
- `auth_config`:token、API key、basic 用户名密码,API key 支持 header 或 query。
|
||||
- `config.target_schema`:例如 `vessel_ais`。
|
||||
- `config.target_schema`:例如 `vessel_ais`、`geo_points` 或 `generic_records`。
|
||||
- `config.delivery_mode`:REST 默认 `polling`,WebSocket 默认 `realtime_stream`。
|
||||
- `config.merge_target_source`:记录该自定义源补充哪个内置数据,例如 `barentswatch_vessels`。
|
||||
|
||||
|
||||
@@ -71,6 +71,10 @@ React 路由入口:
|
||||
|
||||
这份文件是 Earth 前端当前最核心的 UI 控制入口。
|
||||
|
||||
Earth 设置面板现在按 `data-settings-tab` 和 `data-settings-tab-panel` 分类组织。桌面端和移动端使用同一组分类语义:运行、显示、面板、动捕、快捷键、系统。新增设置项时应先判断它属于哪个分类,再补 DOM、持久化字段和恢复逻辑;不要把所有控件继续堆到一个长面板里。
|
||||
|
||||
快捷键配置属于设备本地偏好,由 `controls.js` 负责读取、捕获、启用/禁用和重置。它不应写入后端用户设置,也不应影响其它浏览器。后续新增快捷键时,必须同时提供默认键、显示标签、可禁用状态和重置路径,避免只在 keydown handler 中硬编码。
|
||||
|
||||
### 4. UI 与状态消息
|
||||
|
||||
- [ui.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/ui.js)
|
||||
@@ -123,12 +127,16 @@ Browser Camera provider 的手势识别管线在 [motion-browser-provider.js](/h
|
||||
|
||||
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
- [terrain.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/terrain.js)
|
||||
- [country-boundaries.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/country-boundaries.js)
|
||||
|
||||
职责:
|
||||
|
||||
- 地球球体、云层、大气
|
||||
- 真实地形 mesh
|
||||
- terrain tile 拉取、解码、位移、着色
|
||||
- 海陆基座与国界底图的整球 overlay
|
||||
|
||||
Earth 地表是多层近似同心球,不是单一 mesh。`earth.js` 的基座球、高清材质 overlay、云层/大气,以及 `country-boundaries.js` 的海陆基座都需要明确半径间距。远距视图下 GPU 深度精度会下降,相邻 shell 过近会 z-fighting,表现为黑色闪烁块或雪花。当前稳定策略是让海陆基座使用 `landAltitudeOffset = 0.32`,高清材质使用 `textureOverlayAltitudeOffset = 0.48`;后续新增或调整整球地表 overlay 时,必须同步检查 [Earth 渲染图层顺序](/home/ray/dev/linkong/planet/docs/technical/zh/earth-render-layer-order.md),并在 50% 缩放视图验证。
|
||||
|
||||
### 7. 图层模块
|
||||
|
||||
@@ -152,6 +160,8 @@ Browser Camera provider 的手势识别管线在 [motion-browser-provider.js](/h
|
||||
|
||||
`tv.js` 管理 `media-panel` 里的直播 / 态势新闻 tab。toolbar 打开或切换 TV/新闻时,会通过 `earth:tv-visibility-change` 和 `earth:tv-tab-change` 回写 Earth 设置:面板可见性仍按 desktop/mobile viewport 存在 `views.<scope>.panelVisibility.media-panel`,当前 tab 存在 `shared.mediaPanelActiveTab`,因此刷新页面后能恢复用户上次打开的直播或新闻状态。`closeTransientMobileOverlays()` 这类临时收起会带 `persist:false`,不会覆盖用户偏好。
|
||||
|
||||
`brand.js` 管理 Earth HUD 品牌资源。默认品牌来自静态资源,运行时覆盖值来自 `/api/v1/earth/brand`,上传的图片通过 `/earth-brand-assets/...` 读取。前端必须把 logo/title 图片和文本 fallback 分开处理:图片加载失败时显示文本标题,文本字段为空时使用后端默认值,避免 HUD 品牌区空白。控制台的 Earth 内容页负责保存和重置品牌配置,Earth 前端只消费结果。
|
||||
|
||||
其中 Earth 启动加载链现在也拆成了两层:
|
||||
|
||||
- `controls.js`
|
||||
@@ -422,6 +432,7 @@ Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/
|
||||
- 图层控制开关:`地形 / 卫星 / 海缆 / BGP`
|
||||
- 卫星显示偏好:`卫星显示风格 / 卫星呼吸闪烁 / 真实卫星高度 / 轨迹显示`
|
||||
- 地表 hover 提示偏好:`国家 / 位置 / 完整`
|
||||
- 国界精度偏好:低精 fallback / 高精 PMTiles
|
||||
- 地形透明度
|
||||
|
||||
也就是说,Earth 设置不是一次性 UI 状态了,而是本地设备级偏好。后续如果再加入新的设置项,应优先接入同一条持久化链,而不是各自散着写 `localStorage`。
|
||||
@@ -432,6 +443,8 @@ Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/
|
||||
|
||||
SGP4 传播输出是惯性系位置,不能直接当成 Earth 的经纬度固定坐标使用。`satellites.js` 会用当前时间的 `gstime` 把 ECI/TEME 位置转换到 ECF,再映射到 `latLonToVector3()` 使用的 Three.js 坐标轴。卫星点和短尾迹使用随采样时间变化的地固坐标,表示相对当前地球表面的实际位置;锁定后的预测轨道线使用锁定时刻固定的 `gstime`,把未来一圈惯性轨道投到当前地球姿态上显示,因此会闭合,并且轨道面倾角应与详情卡一致。fallback 预测轨道也必须使用真正的 RAAN + inclination 轨道平面公式,不能把 inclination 当成恒定纬度。
|
||||
|
||||
国界精度偏好独立存储在 `country-boundaries.js` 的 `planet.earth.boundaries.highPrecisionEnabled`。未开启高精时,即使本机已经有高精 manifest/PMTiles,也继续加载低精 `countries-admin0.min.geojson` fallback;开启高精但高精产物缺失时,Earth 工具栏设置会调用 `/api/v1/earth/boundaries/build` 启动后台构建并轮询进度。构建成功后调用 `reloadCountryBoundaries()` 热切换,不再刷新整个页面。国界 hover 与 tooltip 解耦:只要地表坐标落在国界 polygon 内就保持高亮;如果鼠标同时命中卫星、船只、BGP 等 interactable,tooltip 显示 interactable 信息,但国界高亮不应闪烁。
|
||||
|
||||
## 当前地形链路
|
||||
|
||||
真实地形首次启用会慢,原因不只是一个:
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
| Earth 基座 specular | `EARTH_MATERIAL_CONFIG.specular` | `0x1a2d45` | `MeshPhongMaterial.specular` |
|
||||
| Earth 基座 shininess | `EARTH_MATERIAL_CONFIG.shininess` | `12` | `MeshPhongMaterial.shininess` |
|
||||
| Earth 基座 opacity | `EARTH_MATERIAL_CONFIG.opacity` | `1` | `MeshPhongMaterial.opacity` |
|
||||
| 高清材质半径偏移 | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.1` | 独立高清材质球半径 |
|
||||
| 高清材质半径偏移 | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.48` | 独立高清材质球半径;必须与海陆基座和地球基座保持足够深度间距,避免远距 z-fighting |
|
||||
| 高清材质透明度 | `EARTH_MATERIAL_CONFIG.textureOverlayOpacity` | `0.88` | 高清材质 `MeshPhongMaterial.opacity` |
|
||||
| 高清材质 renderOrder | `EARTH_MATERIAL_CONFIG.textureOverlayRenderOrder` | `0.96` | `_earthTextureOverlay.renderOrder` |
|
||||
| 高清材质 specular | `EARTH_MATERIAL_CONFIG.textureOverlaySpecular` | `0x05080d` | 降低直射区域镜面高光,避免贴图死白 |
|
||||
@@ -84,11 +84,19 @@
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 国界数据路径 | `COUNTRY_BOUNDARY_CONFIG.dataPath` | `"/earth/data/countries-admin0.min.geojson"` | GeoJSON 输入 |
|
||||
| 国界瓦片 manifest | `COUNTRY_BOUNDARY_CONFIG.tileManifestPath` | `"/earth/data/boundaries/v1/manifest.json"` | 高精 PMTiles manifest;缺失时使用低精度 fallback |
|
||||
| 国界瓦片 provider | `COUNTRY_BOUNDARY_CONFIG.tileProvider` | `"auto"` | 优先高精 PMTiles/MVT,缺失时降级到 legacy GeoJSON |
|
||||
| PMTiles 产物路径 | `COUNTRY_BOUNDARY_CONFIG.pmtilesPath` | `"/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"` | 生产单文件 PMTiles/MVT artifact |
|
||||
| 低精度 fallback | `COUNTRY_BOUNDARY_CONFIG.legacyFallbackPath` | `"/earth/data/countries-admin0.min.geojson"` | 本地未构建高精国界时的缺省海陆基座和 hover 数据 |
|
||||
| MVT 图层名 | `COUNTRY_BOUNDARY_CONFIG.mvtLayerNames` | `boundary_admin0 / boundary_disputed_internal / coastline / claim_line` | PMTiles provider 解码时读取的固定 layer 名 |
|
||||
| 国界瓦片基础路径 | `COUNTRY_BOUNDARY_CONFIG.tileBasePath` | `"/earth/data/boundaries/v1/"` | PMTiles manifest 基础路径 |
|
||||
| 国界瓦片缩放阈值 | `COUNTRY_BOUNDARY_CONFIG.tileZoomThresholds` | `1.6 -> z5`, `2.8 -> z6`, `3.4 -> z7`, `4.0 -> z8`, `4.6 -> z9`, `5.2 -> z10` | 生产 PMTiles zoom 选择 |
|
||||
| 国界瓦片缓存上限 | `COUNTRY_BOUNDARY_CONFIG.tileCacheLimit` | `150` | 前端已加载瓦片几何的 LRU 缓存条目数 |
|
||||
| 国界瓦片 debounce | `COUNTRY_BOUNDARY_CONFIG.tileDebounceMs` | `180` | 视野变化后请求可见瓦片前的防抖时间 |
|
||||
| 海洋填充色 | local `OCEAN_HEX` | `0x010609` | 海陆基座 canvas 背景 |
|
||||
| 陆地填充色 | `COUNTRY_BOUNDARY_CONFIG.landColor` | `0x080f1b` | 海陆基座 canvas 陆地 |
|
||||
| 海陆基座透明度 | `COUNTRY_BOUNDARY_CONFIG.landOpacity` | `1.0` | `MeshBasicMaterial.opacity` |
|
||||
| 海陆基座半径偏移 | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.08` | `country-land-ocean` 半径 |
|
||||
| 海陆基座半径偏移 | `COUNTRY_BOUNDARY_CONFIG.landAltitudeOffset` | `0.32` | `country-land-ocean` 半径;与地球基座拉开以避免 50% 缩放时雪花/黑块闪烁 |
|
||||
| 海陆基座 renderOrder | `COUNTRY_BOUNDARY_CONFIG.landRenderOrder` | `0.86` | `country-land-ocean.renderOrder` |
|
||||
| 海陆 mask 尺寸 | `landMaskWidth / landMaskHeight` | `2048 / 1024` | canvas / DataTexture 尺寸 |
|
||||
| 国界 tint 颜色 | `COUNTRY_BOUNDARY_CONFIG.tintColor` | `0x0b1830` | 高清材质关闭时 tint |
|
||||
@@ -97,16 +105,16 @@
|
||||
| 国界线颜色 | `COUNTRY_BOUNDARY_CONFIG.lineColor` | `0x7fc7ff` | 普通国界线 |
|
||||
| 国界线透明度 | `COUNTRY_BOUNDARY_CONFIG.lineOpacity` | `0.58` | 普通国界线 opacity |
|
||||
| 国界线 hover 时压暗透明度 | `COUNTRY_BOUNDARY_CONFIG.dimmedLineOpacity` | `0.18` | hover 时普通国界线 opacity |
|
||||
| 国界线半径偏移 | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.115` | 普通国界线半径;略高于高清材质 `0.10`,低于地形基准 `0.16`,减少悬浮感 |
|
||||
| 国界线半径偏移 | `COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset` | `0.115` | 普通国界线半径;线层靠 renderOrder 和独立 geometry 叠加,不作为整球基座深度间距参考 |
|
||||
| 国界线 renderOrder | `COUNTRY_BOUNDARY_CONFIG.lineRenderOrder` | `2.2` | 普通国界线层级 |
|
||||
| 国界 hover 颜色 | `COUNTRY_BOUNDARY_CONFIG.hoverLineColor` | `0xff3b1f` | 霓虹红橘 |
|
||||
| 国界 hover 透明度 | `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity` | `1.0` | hover 实线 opacity |
|
||||
| 国界 hover 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.14` | hover 实线半径;贴近地表但高于普通国界线 |
|
||||
| 国界 hover 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset` | `0.115` | hover 实线半径;与普通国界线同源几何对齐,避免高亮切换时出现重影 |
|
||||
| 国界 hover renderOrder | `COUNTRY_BOUNDARY_CONFIG.hoverLineRenderOrder` | `2.3` | hover 实线层级 |
|
||||
| 国界 hover glow 透明度 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity` | `0.38` | glow 线 opacity |
|
||||
| 国界 hover glow 线宽 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowLineWidth` | `3` | glow `LineBasicMaterial.linewidth` |
|
||||
| 国界 hover glow 层级偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRenderOrderOffset` | `0.01` | glow renderOrder = `2.29` |
|
||||
| 国界 hover glow 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0.04` | glow 半径 = hover 半径 + 0.04 |
|
||||
| 国界 hover glow 半径偏移 | `COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset` | `0` | glow 与 hover 实线共用半径,避免海岸细节处双线错位 |
|
||||
|
||||
## 真实地形
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
|
||||
## 采集器配置方式
|
||||
|
||||
`news_live_streams` 不需要单独新页面,直接复用控制台 `/settings` 的“采集器设置”:
|
||||
`news_live_streams` 不需要单独新页面,直接复用控制台 `/collection-management` 的“采集器”配置:
|
||||
|
||||
- `endpoint`
|
||||
- 频道目录 JSON API 地址
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
| -1 | Earth 遮挡球 | `earth.js` | 地球内侧不可见球 | 写入深度 | 遮挡地球背面的对象。 |
|
||||
| 0 | Earth 基座球 | `earth.js` | `CONFIG.earthRadius` | 地表拾取兜底目标 | 深色基座,所有可选地图层关闭时仍可见。 |
|
||||
| 0.2 | 国界暗色 tint | `country-boundaries.js` | `tintAltitudeOffset` | 禁用 raycast | 高清材质关闭时使用。 |
|
||||
| 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用。 |
|
||||
| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充。 |
|
||||
| 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset = 0.32`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用;半径与基座球拉开以避免远距 z-fighting。 |
|
||||
| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset = 0.48` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充;半径必须高于海陆基座并与基座球保持足够间距。 |
|
||||
| 1 | 大气辉光和云图 | `earth.js` | 大气 / 云层球 | 不走普通对象选择路径 | 云图由“大气云图”图层开关控制。 |
|
||||
| 1 | 海缆 / 登陆点 | `cables.js` | 海缆线和登陆点都使用 `renderOrder = 1`;半径偏移都为 `0.2`;登陆点是专用 `THREE.Sprite` 黄色扁平球 | 海缆走海缆拾取路径;登陆点 `depthTest: false` 保持球体完整,并用相机到球心的球体遮挡判断避免背面穿透 | 登陆点和海缆同层贴地,避免地表设施层的凌空感。 |
|
||||
| 1.2 | 真实地形 | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` 加地形位移 | 禁用 raycast | 地形压过高清材质;高清材质关闭时临时隐藏,重新开启后恢复原状态。 |
|
||||
| 2.05 | 经纬线 | `earth.js` | `CONFIG.earthRadius + 0.14` | 禁用 raycast | 低透明度显示在高清材质上。 |
|
||||
| 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset = 0.115` | `depthTest: true`,禁用 raycast | 略高于高清材质 `0.10`,低于地形基准 `0.16`,减少悬浮感;地形 `depthWrite: false`,所以地形开启时仍可见。 |
|
||||
| 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset = 0.115` | `depthTest: true`,禁用 raycast | 线层使用独立 line geometry 与 `renderOrder` 控制;地形 `depthWrite: false`,所以地形开启时仍可见。 |
|
||||
| 2.29 | 国界 hover 光晕 | `country-boundaries.js` | hover 半径加 glow 偏移 | `depthTest: false`,禁用 raycast | 用 additive 光晕增强交界边和地形开启时的 hover 可见性。 |
|
||||
| 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset = 0.14` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;中国和中国(台湾)共享高亮组。 |
|
||||
| 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset = 0.115` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;与普通国界线同源半径对齐,避免重影;中国和中国(台湾)共享高亮组。 |
|
||||
| 3 | 卫星 footprint 填充 / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested;Iridium adapter 的 fill / ring 也使用同一 renderOrder | Footprint 在 land / texture / terrain 和国界线之上,但在算力中心和卫星之下。 |
|
||||
| 3-4.5 | BGP 观测站、事件扩散圈和事件 marker | `bgp.js`, `interactable.js` | BGP 观测站和事件 marker 均使用 `Interactable` 批量 `THREE.Points`;事件 marker 使用 `BGP_EVENT_RENDER_ORDER = 4.5`;观测站主图标使用 `BGP_COLLECTOR_RENDER_ORDER = 4.4` 和 `BGP_CONFIG.collectorAltitudeOffset = 0.2`;事件 overlay 进入 `bgp-event-overlay-layer`;观测站 halo 和覆盖扇形进入 `bgp-collector-radar-layer` | BGP 事件和观测站都通过 `Interactable` 屏幕空间 picking,并参与同坐标避让 | BGP 观测站主图标与船只同层;BGP 事件与算力中心同层;向外扩散圈、观测站雷达/覆盖动画继续由 BGP 业务逻辑驱动。 |
|
||||
| 4.3 | AIS 船只轨迹线 | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1`;`CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | 跟随船只显隐,不单独参与拾取 | 选中船只后显示最近轨迹,低于船只 marker。 |
|
||||
@@ -48,6 +48,17 @@
|
||||
| 国界线 off | 只隐藏可交互国界线和 hover,高亮状态会清除;海陆基座填充仍作为 Earth 底图保留。 |
|
||||
| 真实卫星高度 off | 卫星点、轨迹和预测轨道都使用旧版同层球面;缺失 TLE 或传播失败的卫星也使用同一 fallback 高度。 |
|
||||
|
||||
## 深度间距规则
|
||||
|
||||
Earth 的地表不是单一 mesh,而是多层近似同心球:基座球、海陆基座、高清材质、地形、云层、大气和遮挡球。近距看起来只差几个小数的半径偏移,在 50% 这类远距视图下会被深度缓冲压到同一批像素,导致 z-fighting,表现为黑块、雪花或闪烁。
|
||||
|
||||
维护规则:
|
||||
|
||||
- 不要用“远距隐藏图层”作为第一反应;先检查相邻 shell 的 `altitudeOffset`、`renderOrder`、`depthTest` 和 `depthWrite`。
|
||||
- 海陆基座和高清材质这类整球 overlay 必须与 `CONFIG.earthRadius` 保持明确间距;当前稳定值为 `landAltitudeOffset = 0.32`、`textureOverlayAltitudeOffset = 0.48`。
|
||||
- 新增整球或近整球地表 overlay 时,必须在 50% 缩放和常用近距视图各截一次图,确认没有黑块、雪花、闪烁,也没有明显漂浮感。
|
||||
- 如果必须调整这些半径,需同步更新本文和 `frontend/public/earth/js/constants.js` 的注释/常量意图。
|
||||
|
||||
## 交互规则
|
||||
|
||||
| 交互 | 当前规则 |
|
||||
|
||||
@@ -55,12 +55,12 @@ tasklist /svc /fi "PID eq 4700"
|
||||
临时排障可以在管理员 PowerShell 中停止 IP Helper:
|
||||
|
||||
```powershell
|
||||
Stop-Service iphlpsvc
|
||||
Stop-Service iphlpsvc -Force
|
||||
```
|
||||
|
||||
这可能影响部分网络、代理或转发能力。长期不推荐禁用该服务;如果必须保留 Windows 转发,改用不同后端端口更稳。
|
||||
这可能影响部分网络、代理或转发能力。长期不推荐禁用该服务;`--allow-lan` 会直接绑定 `3000` / `8000` / `8010`,不再需要保留持久 portproxy。
|
||||
|
||||
如果脚本输出 `failed-stop-service` 或 `failed-stop-process`,说明当前权限无法清理 Windows listener。脚本会停止启动,避免后端再次遇到同一端口冲突。
|
||||
如果脚本输出 `Windows 侧端口 ... 存在监听者`,或 Vite 报 `Port 3000 is already in use` 后显示 `Windows listener ... services=iphlpsvc`,说明旧的 Windows listener 仍在占用端口。脚本会请求管理员 PowerShell 清理对应端口;如果自动清理被取消,再手动检查 `netsh interface portproxy show all`,删除对应 `listenport` 规则。如果没有 portproxy 规则,再用 `netstat` / `tasklist` 确认服务,必要时临时 `Stop-Service iphlpsvc -Force`。清理旧规则后重新运行 `./planet.sh restart --allow-lan`,局域网仍访问 `3000` / `8000` / `8010`。
|
||||
|
||||
### 默认端口冲突时应该改哪些参数?
|
||||
|
||||
@@ -89,6 +89,7 @@ Stop-Service iphlpsvc
|
||||
# 在 WSL 或运行 Planet 的 shell 中
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
curl http://localhost:8010/health
|
||||
```
|
||||
|
||||
再到 Windows PowerShell 验证:
|
||||
@@ -96,6 +97,7 @@ curl http://localhost:8000/health
|
||||
```powershell
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
curl http://localhost:8010/health
|
||||
```
|
||||
|
||||
如果 WSL 和 Windows localhost 都通,但手机或其他电脑访问不通,再考虑局域网开放:
|
||||
@@ -110,22 +112,28 @@ curl http://localhost:8000/health
|
||||
./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 侧转发或防火墙。
|
||||
如果 `ss -ltnp` 显示前端已经监听 `0.0.0.0:3000`,但 Windows PowerShell 中 `Test-NetConnection <Windows局域网IP> -Port 3000` 仍失败,问题通常不在 Vite 或 `.zshrc`,而是在 Windows 侧端口占用、旧 `portproxy` 或防火墙。
|
||||
|
||||
传统 WSL NAT 场景下,管理员 PowerShell 中配置 portproxy 和防火墙:
|
||||
`./planet.sh start --allow-lan` 会直接开放 `3000` / `8000` / `8010`,并在启动前检测端口、旧 `portproxy` 和 Windows 防火墙规则。端口被 Windows 侧 listener 占用时,脚本会请求管理员 PowerShell 清理;缺少入站放行时,也会触发一次 UAC 管理员 PowerShell 请求来自动创建。若自动请求被取消,可以手动清理:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8010
|
||||
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010
|
||||
```
|
||||
|
||||
局域网设备访问 Windows 对外端口,例如 `http://<Windows局域网IP>:3000/earth`。
|
||||
|
||||
如果 `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
|
||||
New-NetFirewallHyperVRule -Name "Planet-AIProvider-8010" -DisplayName "Planet AI Provider 8010" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 8010 -Action Allow
|
||||
```
|
||||
|
||||
也可以用下面命令确认当前 Hyper-V firewall 状态:
|
||||
@@ -288,7 +296,7 @@ USB 摄像头透传到 WSL 属于高级路径;脚本不会默认把无摄像
|
||||
|
||||
### 采集器连接验证通过,但正式采集拿不到凭证怎么办?
|
||||
|
||||
连接验证会读取控制台保存配置、环境变量和部分 `~/.zshrc` 凭证。正式采集更推荐把凭证保存到“设置 -> 采集器设置”,尤其是 AISStream 这类长连接 collector。
|
||||
连接验证会读取控制台保存配置、环境变量和部分 `~/.zshrc` 凭证。正式采集更推荐把凭证保存到“采集管理 -> 采集器”,尤其是 AISStream 这类长连接 collector。
|
||||
|
||||
如果只把 `AISSTREAM_API_KEY` 放在 `~/.zshrc`,需要确认后端进程实际继承了该变量。否则可能出现连接验证可用,但 collector 运行时没有 key 的情况。
|
||||
|
||||
@@ -302,7 +310,7 @@ export BARENTSWATCH_CLIENT_ID="..."
|
||||
export BARENTSWATCH_CLIENT_SECRET="..."
|
||||
```
|
||||
|
||||
稳定运行时,优先在控制台采集器设置中保存凭证,保证连接验证、采集任务和 Earth 实时聚合使用同一份配置。
|
||||
稳定运行时,优先在控制台“采集管理 -> 采集器”中保存凭证,保证连接验证、采集任务和 Earth 实时聚合使用同一份配置。
|
||||
|
||||
## Docs / 权限
|
||||
|
||||
@@ -317,12 +325,29 @@ Docs 按 Gatekeeper 权限组控制可见性:
|
||||
|
||||
## Earth 常见操作
|
||||
|
||||
### 为什么国界提示“未配置 endpoint”或只能看到低精度?
|
||||
|
||||
国界已经从采集器体系移出,不再通过数据源采集任务生成。低精度国界是随前端打包的兜底资产,本地未构建高精 PMTiles 时会自动使用它。
|
||||
|
||||
要启用高精国界,有两条入口:
|
||||
|
||||
- Earth 页面齿轮设置里的“国界精度”:切到“高精”会启动首次后台下载/构建,并显示百分比,完成后自动应用。
|
||||
- 控制台 `运维与配置 -> Earth 内容 -> 国界精度`:适合查看 provider、manifest、PMTiles、fallback 状态,编辑源配置 JSON,或手动重建。
|
||||
|
||||
如果看到“更新源未配置完整”,先到 `Earth 内容 -> 国界精度` 保存源配置;本机私有配置写入 `config/earth-boundary-sources.local.json`,不要提交到仓库。没有高精产物时,使用低精 fallback 是正常行为。
|
||||
|
||||
### Earth 位置候选采集后没有写入怎么办?
|
||||
|
||||
“采集候选”和“保存候选”是两步。候选可以先在 Earth 上预览,只有点击保存或使用待定位列表中的“一键采用”后,才会写入维表并刷新图层。
|
||||
|
||||
算力中心候选保存后会写入 `compute_center_locations`。没有可用候选的记录会保留在待定位列表中,系统不会用国家中心点或硬编码 hint 伪造位置。
|
||||
|
||||
### Earth 品牌 logo 或标题改完后为什么没恢复默认?
|
||||
|
||||
Earth 品牌资源在控制台 `运维与配置 -> Earth 内容 -> 品牌资源` 中维护。上传图片后页面会使用返回的 Earth 品牌资产地址;如果只是清空标题、ARIA 文案等文本字段,系统会回退到默认标题,避免出现空白品牌。
|
||||
|
||||
要恢复发布包自带的默认 logo、标题图和文案,使用“重置品牌资源”。只刷新 Earth 页面不会删除已经保存的运行时品牌配置。
|
||||
|
||||
### 为什么卫星看起来不在同一个球面上?
|
||||
|
||||
Earth 默认开启“真实卫星高度”。卫星位置仍来自 TLE/SGP4,但高度会经过压缩映射:低轨卫星靠近地球,高轨卫星更远,同时保持在当前视图可读范围内。最高显示偏移使用 `25`,约等于当前地球显示半径的四分之一;这是视觉上区分 GEO / MEO / LEO 和保持镜头可读性的折中,不是把真实公里数按比例直接画出来。这个设置也会影响卫星轨迹和锁定后的预测轨道。
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
- `/alerts/situational`
|
||||
- `/bgp`
|
||||
- `/ai`
|
||||
- `/earth-content`
|
||||
- `/collection-management`
|
||||
- `/settings`
|
||||
|
||||
`/earth` 是独立展示页,不属于控制台骨架。
|
||||
@@ -288,9 +290,9 @@
|
||||
- 点击名称打开只读抽屉。
|
||||
- 抽屉中明确显示“内置数据源”或“自定义数据源”。
|
||||
- endpoint、headers、config 只展示,不在这里编辑。
|
||||
- 需要凭证的采集器提示用户到“设置 -> 采集器设置”维护。
|
||||
- 需要凭证的采集器提示用户到“采集管理 -> 采集器”维护。
|
||||
|
||||
这个边界很重要:后续不要把自定义数据源编辑、内置 endpoint 覆盖或凭证表单再塞回 `/datasources`。这些配置入口统一放在 `/settings?tab=collector_credentials`。
|
||||
这个边界很重要:后续不要把自定义数据源编辑、内置 endpoint 覆盖或凭证表单再塞回 `/datasources`。这些配置入口统一放在 `/collection-management?tab=collector_credentials`。
|
||||
|
||||
页面顶部的总进度区域新增 `采集中 N` 标签:
|
||||
|
||||
@@ -303,7 +305,7 @@
|
||||
|
||||
### 采集器设置页
|
||||
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) 中的 `collector_credentials` tab 当前显示为“采集器设置”。
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) 会按路由进入三种模式:`/settings` 是系统设置,`/earth-content` 是 Earth 内容,`/collection-management` 是采集管理。`collector_credentials` tab 当前在 `/collection-management` 下显示为“采集器”。
|
||||
|
||||
当前页面边界:
|
||||
|
||||
@@ -334,6 +336,16 @@
|
||||
|
||||
- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)
|
||||
|
||||
### Earth 内容页
|
||||
|
||||
`/earth-content` 复用 [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) 的单屏 tab 容器,但页面责任与系统设置分离:
|
||||
|
||||
- `电视直播` 迁移原直播源配置,继续管理 Earth 媒体面板内容源。
|
||||
- `国界精度` 管理 Earth 静态国界资产:provider 状态、低精 fallback、高精 manifest/PMTiles、源配置 JSON 和构建动作。
|
||||
- `地球底图`、`图层资源`、`三维素材`、`新闻锚点策略` 是占位页,只显示模块待接入,不造假接口或假数据。
|
||||
|
||||
系统级 `/settings` 不应再新增 Earth 体验资源或采集生命周期 tab;采集相关入口属于 `/collection-management`,Earth 展示资源属于 `/earth-content`。
|
||||
|
||||
### 3. 复杂工作区页面
|
||||
|
||||
例如:
|
||||
|
||||
@@ -76,15 +76,17 @@
|
||||
| BGP 告警 | `/alerts/bgp` | BGP 相关告警 |
|
||||
| 态势告警 | `/alerts/situational` | 态势研判告警 |
|
||||
| AI | `/ai` | 模型供应商、工具、测试台 |
|
||||
| Earth 内容 | `/earth-content` | 电视直播、国界精度、底图和图层资源入口 |
|
||||
| 采集管理 | `/collection-management` | 采集器、采集调度、采集历史入口 |
|
||||
| 系统日志 | `/logs` | 通常仅 super admin 可见 |
|
||||
| 用户管理 | `/users` | 创建/删除/改角色/调权限组 |
|
||||
| 系统配置 | `/settings` | 系统、SMTP、TV、采集器设置 |
|
||||
| 系统设置 | `/settings` | 系统显示、通知、安全、SMTP |
|
||||
|
||||
权限不足时菜单项会自动隐藏。如果发现某个菜单看不到,先确认自己的角色和 Gatekeeper 权限组。
|
||||
|
||||
## 配置数据采集器
|
||||
|
||||
`/settings?tab=collector_credentials` 是"采集器设置"页。这里统一维护所有采集器的连接配置,不仅是凭证。
|
||||
`/collection-management?tab=collector_credentials` 是"采集器"页。这里统一维护所有采集器的连接配置,不仅是凭证。旧链接 `/settings?tab=collector_credentials` 会自动跳转到这个入口;数据源目录仍保留在 `/datasources`。
|
||||
|
||||
操作步骤:
|
||||
|
||||
@@ -128,7 +130,7 @@
|
||||
|
||||
操作步骤:
|
||||
|
||||
1. `/settings?tab=collector_credentials` 选择 `AISStream 实时船舶 : aisstream_vessels`
|
||||
1. `/collection-management?tab=collector_credentials` 选择 `AISStream 实时船舶 : aisstream_vessels`
|
||||
2. 在 `AISStream 凭证` 填入 API Key
|
||||
3. Endpoint 保持默认 `wss://stream.aisstream.io/v0/stream`
|
||||
4. 点击插头图标进行连接测试,确认显示 `可用`
|
||||
@@ -142,10 +144,11 @@
|
||||
|
||||
## 配置 AI 凭证
|
||||
|
||||
`/ai?tab=providers` 是 AI 模型管理入口。包含两个核心子 tab:
|
||||
`/ai?tab=providers` 是 AI 模型管理入口。包含三个核心子 tab:
|
||||
|
||||
- `模型供应商`:默认 LLM provider、模型、Base URL、API Key、本地 `aiprovider` 代理和连接测试
|
||||
- `工具`:通过下拉菜单选择具体工具,当前支持 WebSearch 和 OCR
|
||||
- `提示词`:通过功能入口下拉菜单选择新闻汉化、告警研判、BGP 简报等 LLM 任务,手动调整提示词或重置为缺省
|
||||
|
||||
### 模型供应商
|
||||
|
||||
@@ -166,6 +169,10 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
|
||||
- **WebSearch**:provider、API Key、Base URL、最大结果数、超时、高级 provider 参数。未启用时除"启用"开关外其它配置项和连接测试都会置灰
|
||||
- **OCR**:provider、Base URL、API Key、模型/engine、识别语言、超时、最大文件大小、输出格式
|
||||
|
||||
### 提示词
|
||||
|
||||
选择功能入口后,页面会显示当前提示词、是否已自定义、缺省版本和重置按钮。保存只影响该功能入口;重置会恢复当前发布包中的缺省提示词。业务事实、上下文和输出 schema 仍由后端按功能入口自动传入。
|
||||
|
||||
旧链接 `/settings?tab=ai` 会跳到 `/ai?tab=providers`。
|
||||
|
||||
## 系统设置
|
||||
@@ -176,8 +183,27 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
|
||||
- **通知策略**:告警邮件开关、收件邮箱、严重/警告/每日摘要通知
|
||||
- **安全策略**:会话超时、最大登录尝试、密码策略
|
||||
- **SMTP 邮件**:注册和找回密码所需的发件配置(仅 `admin` / `super_admin` 可见)
|
||||
- **电视直播**:电视直播源管理
|
||||
- **AI / WebSearch / OCR**:见上节
|
||||
|
||||
电视直播和国界精度已经移到 `/earth-content`,采集器和采集调度已经移到 `/collection-management`,AI Provider / WebSearch / OCR 在 `/ai`。
|
||||
|
||||
### Earth 内容
|
||||
|
||||
`/earth-content` 位于控制台“运维与配置”下,面向 Earth 前端体验资源:
|
||||
|
||||
- **品牌资源**:维护 Earth HUD 使用的 logo、标题图、标题文本、副标题和描述;上传的图片会保存为 Earth 品牌资产并立即供 Earth 页面读取。
|
||||
- **电视直播**:维护 Earth 媒体面板里的直播源。
|
||||
- **国界精度**:查看当前国界 provider、低精 fallback、高精 PMTiles/manifest 状态,编辑本机源配置并手动构建。
|
||||
- **地球底图**、**图层资源**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。
|
||||
|
||||
Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时,如果本机尚未构建高精资产,会像游戏更新包一样启动后台下载/构建并显示百分比;构建成功后自动应用,无需刷新。切回“低精”只切换本机显示偏好,不重新下载。
|
||||
|
||||
### 采集管理
|
||||
|
||||
`/collection-management` 位于控制台“运维与配置”下,面向采集生命周期:
|
||||
|
||||
- **采集器**:维护 endpoint、请求头、凭证、timeout、retry,并运行连接检查。
|
||||
- **采集调度**:维护原有调度相关设置。
|
||||
- **采集历史 / 快照**:当前是待接入占位页,后续承载 collection task、snapshot、collected data 浏览能力。
|
||||
|
||||
### SMTP 邮件设置
|
||||
|
||||
@@ -207,7 +233,7 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
|
||||
|
||||
## 数据探索
|
||||
|
||||
- `/datasources`:数据源目录。`采集任务` tab 面向一次性/定时采集器,可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“一键采集”触发当前筛选范围。`实时流` tab 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置;接口、凭证、请求头的编辑统一在 `/settings` 的"采集器设置"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表
|
||||
- `/datasources`:数据源目录。`采集任务` tab 面向一次性/定时采集器,可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“一键采集”触发当前筛选范围。`实时流` tab 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置;接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表
|
||||
- `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录"
|
||||
- `/bgp`:BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补
|
||||
- `/alerts/system`、`/alerts/bgp`、`/alerts/situational`:系统、BGP、态势告警
|
||||
@@ -258,7 +284,7 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
|
||||
|
||||
### 设置
|
||||
|
||||
设置面板包含:旋转模式 / 巡航模式 / 动捕模式、巡航模块(BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、地球默认大小、地形透明度、重置设置。
|
||||
设置面板按分类组织:运行、显示、面板、动捕、快捷键、系统。里面包含旋转模式 / 巡航模式 / 动捕模式、巡航模块(BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、快捷键启用与改键、地球默认大小、地形透明度、重置设置。
|
||||
|
||||
“真实卫星高度”默认开启:卫星会按 TLE/SGP4 算出的真实轨道高度做压缩分层显示,低轨仍靠近地球,高轨会更远但不会脱离当前视图。高轨显示高度会被压到地球半径外约四分之一以内,这样 GEO / MEO 仍能和 LEO 分层,但不会把视线、轨迹和选择操作拉得过散;关闭后恢复旧版所有卫星位于同一显示球面的效果。“轨迹显示”控制卫星轨迹线显隐,卫星图层关闭时轨迹也不可见。
|
||||
|
||||
@@ -266,6 +292,8 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
|
||||
|
||||
这些设置保存在浏览器本地存储,换浏览器或清理站点数据后会恢复默认值。
|
||||
|
||||
快捷键也保存在浏览器本地。可以在设置里的“快捷键”分类中单独禁用、重设某个快捷键,或恢复默认键位;这只影响当前浏览器。
|
||||
|
||||
### 视角控制
|
||||
|
||||
| 操作 | 作用 |
|
||||
|
||||
@@ -155,7 +155,7 @@ wait_for_port_release() {
|
||||
- `PORT_PRESTART_RETRIES`:默认 3 次。
|
||||
- `PORT_PRESTART_RETRY_INTERVAL`:默认 2 秒。
|
||||
|
||||
`kill_port_if_requested()` 优先清理当前环境能找到的监听 PID;只有检测到当前运行在 WSL 且没有可杀 PID、但端口仍不可绑定时,才会检查 Windows 侧 listener,并尝试通过 PowerShell 停止对应服务或强制结束对应进程。若没有权限,或 `iphlpsvc` 这类系统服务拒绝停止,脚本会打印 Windows listener 详情并立即停止启动,不再继续拉起服务碰同一个端口错误。非 WSL 环境不会尝试 Windows 清理路径。此时需要用管理员 PowerShell 清理 portproxy/服务占用,或改用其他端口。
|
||||
`kill_port_if_requested()` 优先清理当前环境能找到的监听 PID;只有检测到当前运行在 WSL 且没有可杀 PID、但端口仍不可绑定时,才会检查 Windows 侧 listener,并请求管理员 PowerShell 删除旧 `portproxy`、停止占用端口的服务或强制结束对应进程。若管理员请求被取消,或 `iphlpsvc` 这类系统服务拒绝停止,脚本会打印 Windows listener 详情和管理员 PowerShell 处理命令,然后立即停止启动,不再继续拉起服务碰同一个端口错误。前端 Vite 启动后才发现 `Port 3000 is already in use` 时,也会打印同一套 Windows listener 处理命令。非 WSL 环境不会尝试 Windows 清理路径。`--allow-lan` 直接开放 `3000` / `8000` / `8010`,不再启动额外的 Windows 端口转发进程;旧的持久 portproxy 规则应清理掉。
|
||||
|
||||
## 问题三:端口检测用 Python
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
- 手机或平板演示 Earth
|
||||
- 局域网其他机器访问同一开发实例
|
||||
|
||||
`--allow-lan` 只负责让前端和后端监听 `0.0.0.0`。WSL 中运行时,Windows 本机一般可以通过 `localhost` 访问,但局域网其他机器访问 `http://<Windows局域网IP>:3000` 还需要 Windows 端口转发和防火墙放行。
|
||||
`--allow-lan` 会让前端、后端和 AI Provider 直接对开发机开放:前端 `3000`、后端 `8000`、AI Provider `8010`。脚本启动前会检查这三个端口;如果 WSL/Linux 侧无法释放端口,并检测到 Windows 侧 listener 或旧 `portproxy`,会请求管理员 PowerShell 清理。WSL 中运行时,Windows 本机一般可以通过 `localhost` 访问,局域网其他机器访问 Windows 局域网 IP 时还需要 Windows 防火墙放行。
|
||||
|
||||
建议按顺序排查:
|
||||
|
||||
@@ -128,19 +128,24 @@
|
||||
# 在运行 Planet 的 shell 中
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
ss -ltnp | grep -E ':3000|:8000'
|
||||
curl http://localhost:8010/health
|
||||
ss -ltnp | grep -E ':3000|:8000|:8010'
|
||||
```
|
||||
|
||||
如果看到 `0.0.0.0:3000` 和 `0.0.0.0:8000`,但局域网 IP 仍访问失败,在管理员 PowerShell 中配置:
|
||||
如果服务已经启动但局域网 IP 仍访问失败,优先清理旧 `portproxy` 并确认 Windows 防火墙放行。脚本会自动检测并请求管理员 PowerShell 处理;自动请求被取消时,手动兜底命令如下:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8010
|
||||
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010
|
||||
```
|
||||
|
||||
局域网设备访问 Windows 对外端口,例如 `http://<Windows局域网IP>:3000/earth`、`http://<Windows局域网IP>:8000/health`、`http://<Windows局域网IP>:8010/health`。
|
||||
|
||||
## AI Provider 环境变量与构建
|
||||
|
||||
AI Provider 运行期配置可以放在两处:
|
||||
@@ -236,6 +241,15 @@ uv sync
|
||||
uv run pytest backend/tests/test_otp_service.py
|
||||
```
|
||||
|
||||
## Earth 国界 PMTiles 操作步骤
|
||||
|
||||
1. 在控制台 `运维与配置 -> Earth 内容 -> 国界精度` 保存国界源配置;本机配置写入 `config/earth-boundary-sources.local.json`,不要提交。
|
||||
2. 点击“构建高精国界”,或在 Earth 页面工具栏齿轮中切到“高精”触发首次构建。后端会下载三类源到 `data/earth-boundary-sources/`,生成 source manifest,并调用 PMTiles 构建脚本。
|
||||
3. 构建器需要本机 PATH 里有 `tippecanoe` 和 `pmtiles`。缺工具时接口返回明确错误,不会写入数据源采集记录。
|
||||
4. 构建成功后应输出 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles` 和对应 manifest。
|
||||
5. 部署后打开 Earth,开启“国界线”,放大中国东南海岸、台湾、海南、南海、藏南、科索沃、加沙等区域验证 hover 和边界口径。
|
||||
6. 如果本地没有高精 manifest/PMTiles,Earth 会使用 `frontend/public/earth/data/countries-admin0.min.geojson` 低精度 fallback;如果高精产物存在但瓦片请求失败,按 PMTiles range 请求、manifest provider、Nginx `.pmtiles` 静态返回和 sha256 一致性排查。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [planet.sh 启动机制](/home/ray/dev/linkong/planet/docs/technical/zh/ops-planet-sh-startup.md)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
入口分两类:
|
||||
|
||||
- 公开页:`/earth`(3D 态势)、`/docs`(公共文档)
|
||||
- 登录后:`/admin`(控制台)、`/ai`(AI)、`/settings`(系统配置)
|
||||
- 登录后:`/admin`(控制台)、`/ai`(AI)、`/settings`(系统设置)、`/earth-content`(Earth 内容)、`/collection-management`(采集管理)
|
||||
|
||||
## 2. 注册账号
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
进入 `/admin` 仪表盘后,建议按这个顺序熟悉控制台:
|
||||
|
||||
1. `/settings?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
|
||||
1. `/collection-management?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
|
||||
2. `/ai?tab=providers`:填一个 LLM provider(例如 `minimax` / `openai`)、模型名、Base URL、API Key,点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选
|
||||
3. `/datasources` 或 `/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 采集任务`,AISStream / WebSocket 长连接看 `/datasources -> 实时流` 的健康状态和计数
|
||||
4. `/alerts/system`:看系统告警是否正常
|
||||
|
||||
@@ -16,12 +16,16 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.56.0`
|
||||
- `dev` 当前开发分支历史推导到:`0.60.0`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.60.0` | feature | `dev` | `pending` | Earth 内容/国界运行体验、新闻中文摘要展示、AI Provider 纯净边界和提示词运维配置落地,并补充 Agent Runtime 与 Earth LLM 指令计划 |
|
||||
| `0.59.0` | feature | `dev` | `pending` | Earth 国界迁移为静态资产并恢复低精 fallback,新增工具栏高精构建进度、后台 Earth 内容/采集管理拆分、AI task prompt 管理和新闻锚点队列补丁链路 |
|
||||
| `0.58.0` | feature | `dev` | `pending` | Earth 高精度国界切换到 PMTiles/MVT 和标准源采集器,移除旧低精度兜底,修复远距地表 z-fighting 雪花/黑块,并补齐新闻目标地点队列与文档 |
|
||||
| `0.57.0` | feature | `dev` | `pending` | WSL `--allow-lan` 新增临时 Windows relay,保持 localhost 与局域网同用 3000/8000,并自动处理旧 portproxy、防火墙授权和 Vite ESM 配置 |
|
||||
| `0.56.0` | feature | `dev` | `pending` | 修复 Earth 卫星 ECI/TEME 到 ECF 坐标转换和闭合预测轨道,调校真实高度压缩上限,统一 BGP 光晕色调,并更新超算图标与 Earth HUD/新闻体验 |
|
||||
| `0.55.0` | feature | `dev` | `pending` | Earth 卫星新增真实高度压缩显示开关,轨迹和预测轨道跟随高度模式切换,并补齐设置面板、FAQ、用户手册和开发者文档 |
|
||||
| `0.54.0` | feature | `dev` | `pending` | 新增 Earth 轨迹显示设置并迁移到桌面/移动设置面板,持久化轨迹偏好,同时优化卫星呼吸闪烁参数和算力中心图标资产 |
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
"name": "planet-frontend",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"antd": "^5.12.5",
|
||||
"axios": "^1.6.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"pbf": "^4.0.1",
|
||||
"pmtiles": "^4.4.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
@@ -19,6 +22,7 @@
|
||||
"zustand": "^4.4.7",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
@@ -142,6 +146,10 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="],
|
||||
|
||||
"@mapbox/vector-tile": ["@mapbox/vector-tile@2.0.4", "", { "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^4.0.1" } }, "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg=="],
|
||||
|
||||
"@rc-component/async-validator": ["@rc-component/async-validator@5.1.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA=="],
|
||||
|
||||
"@rc-component/color-picker": ["@rc-component/color-picker@2.0.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6", "@babel/runtime": "^7.23.6", "classnames": "^2.2.6", "rc-util": "^5.38.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q=="],
|
||||
@@ -226,6 +234,10 @@
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||
|
||||
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
|
||||
|
||||
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||
|
||||
"@types/react": ["@types/react@18.3.27", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w=="],
|
||||
@@ -288,6 +300,8 @@
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
@@ -336,12 +350,18 @@
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"pbf": ["pbf@4.0.1", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"pmtiles": ["pmtiles@4.4.1", "", { "dependencies": { "fflate": "^0.8.2" } }, "sha512-5oTeQc/yX/ft1evbpIlnoCZugQuug/iYIAj/ZTqIqzdGek4uZEho99En890EE6NOSI3JTI3IG8R7r8+SltphxA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"protocol-buffers-schema": ["protocol-buffers-schema@3.6.1", "", {}, "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"rc-cascader": ["rc-cascader@3.34.0", "", { "dependencies": { "@babel/runtime": "^7.25.7", "classnames": "^2.3.1", "rc-select": "~14.16.2", "rc-tree": "~5.13.0", "rc-util": "^5.43.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag=="],
|
||||
@@ -430,6 +450,8 @@
|
||||
|
||||
"resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="],
|
||||
|
||||
"resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="],
|
||||
|
||||
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
||||
|
||||
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
@@ -458,6 +480,8 @@
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
@@ -50,6 +50,23 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /earth/data/boundaries/v1/manifest.json {
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~ ^/earth/data/boundaries/.*\.pmtiles$ {
|
||||
types { application/octet-stream pmtiles; }
|
||||
add_header Accept-Ranges bytes;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /earth/data/boundaries/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.56.0",
|
||||
"version": "0.60.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"antd": "^5.12.5",
|
||||
"axios": "^1.6.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"pbf": "^4.0.1",
|
||||
"pmtiles": "^4.4.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
@@ -20,6 +23,7 @@
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/node": "^24.0.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10"
|
||||
|
||||
@@ -1290,6 +1290,53 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-tab {
|
||||
flex: 0 0 auto;
|
||||
min-width: 52px;
|
||||
border: 1px solid rgba(212, 227, 244, 0.1);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent),
|
||||
rgba(255, 255, 255, 0.025);
|
||||
color: var(--hud-text-soft);
|
||||
padding: 9px 14px;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-tab:hover {
|
||||
color: var(--hud-text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-mobile-settings-tab.is-active {
|
||||
color: var(--hud-title);
|
||||
border-color: rgba(122, 180, 255, 0.28);
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.18), transparent 58%),
|
||||
linear-gradient(180deg, rgba(122, 180, 255, 0.18), rgba(82, 123, 186, 0.24));
|
||||
}
|
||||
|
||||
.earth-mobile-settings-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1309,23 +1356,44 @@
|
||||
}
|
||||
|
||||
.earth-mobile-settings-segmented {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
--item-count: 2;
|
||||
--active-index: 0;
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(var(--item-count), minmax(0, 1fr));
|
||||
padding: 4px;
|
||||
border: 1px solid rgba(212, 227, 244, 0.08);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-segmented::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
width: calc((100% - 8px) / var(--item-count));
|
||||
border-radius: 999px;
|
||||
background: rgba(122, 180, 255, 0.16);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
transform: translateX(calc(var(--active-index) * 100%));
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-pill {
|
||||
border: 1px solid rgba(212, 227, 244, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: transparent;
|
||||
color: var(--hud-text-soft);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-pill.is-active {
|
||||
color: var(--hud-title);
|
||||
border-color: rgba(122, 180, 255, 0.24);
|
||||
background: rgba(122, 180, 255, 0.14);
|
||||
}
|
||||
|
||||
.earth-mobile-settings-chip-group {
|
||||
@@ -2143,6 +2211,52 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
gap: var(--hud-gap-md);
|
||||
}
|
||||
|
||||
.earth-settings-tabs {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: calc(6px * var(--settings-scale));
|
||||
padding: calc(2px * var(--settings-scale)) 0 calc(4px * var(--settings-scale));
|
||||
}
|
||||
|
||||
.earth-settings-tab {
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(212, 227, 244, 0.1);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent),
|
||||
rgba(255, 255, 255, 0.025);
|
||||
color: var(--hud-text-soft);
|
||||
padding: calc(7px * var(--settings-scale)) calc(10px * var(--settings-scale));
|
||||
font: inherit;
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-settings-tab:hover {
|
||||
color: var(--hud-text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.earth-settings-tab.is-active {
|
||||
color: var(--hud-title);
|
||||
border-color: rgba(122, 180, 255, 0.28);
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.2), transparent 58%),
|
||||
linear-gradient(180deg, rgba(121, 159, 207, 0.22), rgba(72, 101, 139, 0.28));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 8px 18px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.earth-settings-kicker {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.72rem * var(--hud-scale));
|
||||
@@ -2172,6 +2286,8 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
}
|
||||
|
||||
.earth-settings-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-inline: 8px;
|
||||
padding-right: 10px;
|
||||
@@ -2256,7 +2372,11 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
}
|
||||
|
||||
.earth-settings-segmented {
|
||||
display: inline-flex;
|
||||
--item-count: 2;
|
||||
--active-index: 0;
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(var(--item-count), minmax(0, 1fr));
|
||||
align-self: flex-start;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
@@ -2265,10 +2385,30 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(212, 227, 244, 0.08);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earth-settings-segmented::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
width: calc((100% - 8px) / var(--item-count));
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%),
|
||||
linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 8px 18px rgba(0, 0, 0, 0.2);
|
||||
transform: translateX(calc(var(--active-index) * 100%));
|
||||
transition: transform 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.earth-settings-segmented-btn {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--hud-text-soft);
|
||||
@@ -2280,9 +2420,7 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
@@ -2293,12 +2431,73 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
|
||||
.earth-settings-segmented-btn.is-active {
|
||||
color: var(--hud-title);
|
||||
}
|
||||
|
||||
.earth-settings-segmented-btn:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.54;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.earth-settings-boundary-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.earth-settings-segmented--boundary {
|
||||
flex: 0 1 auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.earth-settings-reload-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(30px * var(--hud-scale));
|
||||
height: calc(30px * var(--hud-scale));
|
||||
border: 1px solid rgba(122, 180, 255, 0.24);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%),
|
||||
linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 8px 18px rgba(0, 0, 0, 0.2);
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.2), transparent 58%),
|
||||
linear-gradient(180deg, rgba(121, 159, 207, 0.18), rgba(72, 101, 139, 0.24));
|
||||
color: var(--hud-title);
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-settings-reload-action--text {
|
||||
width: auto;
|
||||
min-width: calc(46px * var(--hud-scale));
|
||||
padding: 0 calc(12px * var(--hud-scale));
|
||||
font: inherit;
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.earth-settings-reload-action[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-settings-reload-action:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(125, 197, 255, 0.46);
|
||||
}
|
||||
|
||||
.earth-settings-reload-action:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.52;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.earth-settings-reload-action .material-symbols-rounded {
|
||||
font-size: calc(1rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-settings-chip-group {
|
||||
@@ -2432,6 +2631,41 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.earth-boundary-progress {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.earth-boundary-progress[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-boundary-progress__track {
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(135, 162, 190, 0.26);
|
||||
}
|
||||
|
||||
.earth-boundary-progress__bar {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #55d6be, #7cb7ff);
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.earth-boundary-progress__value {
|
||||
min-width: 40px;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.66rem * var(--hud-scale));
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.earth-settings-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2517,6 +2751,154 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-shortcut-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.earth-shortcut-category {
|
||||
margin-top: 4px;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.62rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.earth-shortcut-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgba(212, 227, 244, 0.08);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.earth-shortcut-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.earth-shortcut-label {
|
||||
color: var(--hud-text);
|
||||
font-size: calc(0.72rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.earth-shortcut-alias {
|
||||
color: var(--hud-text-muted);
|
||||
font-size: calc(0.62rem * var(--hud-scale));
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.earth-shortcut-key {
|
||||
min-width: calc(68px * var(--hud-scale));
|
||||
border: 1px solid rgba(122, 180, 255, 0.24);
|
||||
border-radius: calc(6px * var(--hud-scale));
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.16), transparent 58%),
|
||||
linear-gradient(180deg, rgba(121, 159, 207, 0.16), rgba(72, 101, 139, 0.2));
|
||||
color: var(--hud-title);
|
||||
padding: calc(6px * var(--hud-scale)) calc(10px * var(--hud-scale));
|
||||
font: inherit;
|
||||
font-size: calc(0.68rem * var(--hud-scale));
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.earth-shortcut-key.is-capturing {
|
||||
border-color: rgba(110, 226, 188, 0.52);
|
||||
color: #d8fff3;
|
||||
}
|
||||
|
||||
.earth-shortcut-reset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(28px * var(--hud-scale));
|
||||
height: calc(28px * var(--hud-scale));
|
||||
border: 1px solid rgba(212, 227, 244, 0.1);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
color: var(--hud-text-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.earth-shortcut-enable {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: calc(34px * var(--hud-scale));
|
||||
height: calc(20px * var(--hud-scale));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.earth-shortcut-enable input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.earth-shortcut-enable .earth-settings-switch-track {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.earth-shortcut-enable .earth-settings-switch-track::after {
|
||||
width: calc(14px * var(--hud-scale));
|
||||
height: calc(14px * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-shortcut-enable input:checked + .earth-settings-switch-track {
|
||||
background: linear-gradient(180deg, rgba(143, 185, 255, 0.72), rgba(104, 147, 221, 0.78));
|
||||
border-color: rgba(223, 236, 252, 0.28);
|
||||
}
|
||||
|
||||
.earth-shortcut-enable input:checked + .earth-settings-switch-track::after {
|
||||
transform: translate(calc(14px * var(--hud-scale)), -50%);
|
||||
}
|
||||
|
||||
.earth-shortcut-reset .material-symbols-rounded {
|
||||
font-size: calc(0.9rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-settings-shortcut-actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-card .earth-shortcut-category {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-card .earth-shortcut-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-card .earth-shortcut-label {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-card .earth-shortcut-alias {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-card .earth-shortcut-key {
|
||||
min-width: 70px;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-card .earth-shortcut-reset {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.hud-panel-motion-debug {
|
||||
position: absolute;
|
||||
right: calc(24px * var(--hud-scale));
|
||||
|
||||
@@ -92,6 +92,16 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand__title-text {
|
||||
color: var(--hud-text);
|
||||
font-size: calc(1rem * var(--hud-scale) * var(--brand-scale));
|
||||
line-height: 1.18;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hud-panel-brand .earth-brand__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://esm.sh/three@0.128.0",
|
||||
"pmtiles": "https://esm.sh/pmtiles@4.4.1",
|
||||
"@mapbox/vector-tile": "https://esm.sh/@mapbox/vector-tile@2.0.4",
|
||||
"pbf": "https://esm.sh/pbf@4.0.1",
|
||||
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
|
||||
"satellite.js": "https://esm.sh/satellite.js@5.0.0",
|
||||
"hls.js": "https://esm.sh/hls.js@1.6.15",
|
||||
@@ -846,8 +849,16 @@
|
||||
<span class="earth-mobile-page-kicker">Settings</span>
|
||||
<span class="earth-mobile-page-summary">仅保留移动端仍有意义的 Earth 配置</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">旋转</div>
|
||||
<div class="earth-mobile-settings-tabs" role="tablist" aria-label="移动端设置分类">
|
||||
<button type="button" class="earth-mobile-settings-tab is-active" data-settings-tab="runtime" aria-selected="true">运行</button>
|
||||
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="display" aria-selected="false">显示</button>
|
||||
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="panels" aria-selected="false">面板</button>
|
||||
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="motion" aria-selected="false">动捕</button>
|
||||
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="shortcuts" aria-selected="false">快捷键</button>
|
||||
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="system" aria-selected="false">系统</button>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="runtime">
|
||||
<div class="earth-mobile-settings-title">运行</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">旋转模式</span>
|
||||
@@ -874,8 +885,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">视图</div>
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="display" hidden>
|
||||
<div class="earth-mobile-settings-title">显示</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">卫星显示风格</span>
|
||||
@@ -947,10 +958,115 @@
|
||||
<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-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">地球默认大小</span>
|
||||
<span class="earth-mobile-settings-subtitle">用于重置视角、缩放重置和巡航视图</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-slider-row">
|
||||
<input
|
||||
class="earth-mobile-settings-slider"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.01"
|
||||
value="1"
|
||||
data-default-earth-size-slider
|
||||
aria-label="移动端调整地球默认大小"
|
||||
>
|
||||
<span class="earth-mobile-settings-slider-value" data-default-earth-size-value>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">地形透明度</span>
|
||||
<span class="earth-mobile-settings-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-slider-row">
|
||||
<input
|
||||
class="earth-mobile-settings-slider"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value="0.62"
|
||||
data-terrain-opacity-slider
|
||||
aria-label="移动端调整地形透明度"
|
||||
>
|
||||
<span class="earth-mobile-settings-slider-value" data-terrain-opacity-value>62%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label" data-boundary-precision-status>国界精度</span>
|
||||
<span class="earth-mobile-settings-subtitle" data-boundary-precision-detail>正在读取高清国界状态...</span>
|
||||
</div>
|
||||
<div class="earth-boundary-progress" data-boundary-precision-progress-wrap hidden>
|
||||
<div class="earth-boundary-progress__track">
|
||||
<div class="earth-boundary-progress__bar" data-boundary-precision-progress-bar></div>
|
||||
</div>
|
||||
<span class="earth-boundary-progress__value" data-boundary-precision-progress-value>0%</span>
|
||||
</div>
|
||||
<div class="earth-settings-boundary-action-row">
|
||||
<div class="earth-mobile-settings-segmented earth-mobile-settings-segmented--boundary" role="group" aria-label="移动端选择国界精度">
|
||||
<button type="button" class="earth-mobile-settings-pill is-active" data-boundary-precision-disable aria-pressed="true">低精</button>
|
||||
<button type="button" class="earth-mobile-settings-pill" data-boundary-precision-build aria-pressed="false">高精</button>
|
||||
</div>
|
||||
<button type="button" class="earth-settings-reload-action" data-boundary-precision-rebuild aria-label="重新获取并构建高清国界" title="重新获取并构建" hidden disabled>
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="panels" hidden>
|
||||
<div class="earth-mobile-settings-title">面板</div>
|
||||
<label class="earth-mobile-settings-card">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">图层控制</span>
|
||||
<span class="earth-mobile-settings-subtitle">控制图层控制面板显示</span>
|
||||
</div>
|
||||
<span class="earth-mobile-settings-switch">
|
||||
<input type="checkbox" data-settings-panel="layer-toggles" 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-settings-panel="legend">
|
||||
<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-settings-panel="earth-stats">
|
||||
<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">控制 Live 新闻面板显示</span>
|
||||
</div>
|
||||
<span class="earth-mobile-settings-switch">
|
||||
<input type="checkbox" data-settings-panel="media-panel">
|
||||
<span class="earth-mobile-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="motion" hidden>
|
||||
<div class="earth-mobile-settings-title">动捕</div>
|
||||
<label
|
||||
class="earth-mobile-settings-card"
|
||||
data-gatekeeper-permission="earth.motion_debug"
|
||||
>
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">动捕调试模式</span>
|
||||
<span class="earth-mobile-settings-subtitle">显示摄像头识别到的骨架连线和匹配动作</span>
|
||||
@@ -973,49 +1089,21 @@
|
||||
<button type="button" class="earth-mobile-settings-pill" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="shortcuts" hidden>
|
||||
<div class="earth-mobile-settings-title">快捷键</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">地球默认大小</span>
|
||||
<span class="earth-mobile-settings-subtitle">用于重置视角、缩放重置和巡航视图</span>
|
||||
<span class="earth-mobile-settings-label">键盘控制</span>
|
||||
<span class="earth-mobile-settings-subtitle">点击按键后按下新的快捷键;Esc 取消录入</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-slider-row">
|
||||
<input
|
||||
class="earth-mobile-settings-slider"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.01"
|
||||
value="1"
|
||||
data-default-earth-size-slider
|
||||
aria-label="移动端调整地球默认大小"
|
||||
>
|
||||
<span class="earth-mobile-settings-slider-value" data-default-earth-size-value>100%</span>
|
||||
<div class="earth-shortcut-list" data-shortcut-list></div>
|
||||
<div class="earth-mobile-settings-actions">
|
||||
<button class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button" data-shortcut-reset-all>恢复默认快捷键</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-title">地形</div>
|
||||
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
|
||||
<div class="earth-mobile-settings-copy">
|
||||
<span class="earth-mobile-settings-label">地形透明度</span>
|
||||
<span class="earth-mobile-settings-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-slider-row">
|
||||
<input
|
||||
class="earth-mobile-settings-slider"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value="0.62"
|
||||
data-terrain-opacity-slider
|
||||
aria-label="移动端调整地形透明度"
|
||||
>
|
||||
<span class="earth-mobile-settings-slider-value" data-terrain-opacity-value>62%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group">
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="system" hidden>
|
||||
<div class="earth-mobile-settings-title">系统</div>
|
||||
<div class="earth-mobile-settings-actions">
|
||||
<button id="mobile-settings-reset" class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button">重置设置</button>
|
||||
@@ -1097,9 +1185,17 @@
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="earth-settings-tabs" role="tablist" aria-label="设置分类">
|
||||
<button type="button" class="earth-settings-tab is-active" data-settings-tab="runtime" aria-selected="true">运行</button>
|
||||
<button type="button" class="earth-settings-tab" data-settings-tab="display" aria-selected="false">显示</button>
|
||||
<button type="button" class="earth-settings-tab" data-settings-tab="panels" aria-selected="false">面板</button>
|
||||
<button type="button" class="earth-settings-tab" data-settings-tab="motion" aria-selected="false">动捕</button>
|
||||
<button type="button" class="earth-settings-tab" data-settings-tab="shortcuts" aria-selected="false">快捷键</button>
|
||||
<button type="button" class="earth-settings-tab" data-settings-tab="system" aria-selected="false">系统</button>
|
||||
</div>
|
||||
<div class="earth-settings-content hud-panel__body">
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">旋转</div>
|
||||
<section class="earth-settings-section" data-settings-tab-panel="runtime">
|
||||
<div class="earth-settings-section-title">运行</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
@@ -1191,8 +1287,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">视图</div>
|
||||
<section class="earth-settings-section" data-settings-tab-panel="display" hidden>
|
||||
<div class="earth-settings-section-title">显示</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
@@ -1300,33 +1396,70 @@
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="earth-settings-item"
|
||||
for="toggle-motion-debug"
|
||||
data-gatekeeper-permission="earth.motion_debug"
|
||||
>
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">动捕调试模式</span>
|
||||
<span class="earth-settings-item-subtitle">打开骨架连线面板;未匹配为红线,匹配动作后变绿</span>
|
||||
<span class="earth-settings-item-title">地球默认大小</span>
|
||||
<span class="earth-settings-item-subtitle">用于重置视角、缩放重置和巡航视图的默认缩放比例</span>
|
||||
</div>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-motion-debug" type="checkbox" data-motion-debug-toggle>
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
class="earth-settings-item earth-settings-item--stacked"
|
||||
data-gatekeeper-permission="earth.motion_debug"
|
||||
>
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">动捕输入源</span>
|
||||
<span class="earth-settings-item-subtitle">浏览器摄像头适合网页/SaaS;Motion Agent 适合双摄、RTSP/HTTP 和客户端</span>
|
||||
</div>
|
||||
<div class="earth-settings-segmented" role="group" aria-label="选择动捕输入源">
|
||||
<button type="button" class="earth-settings-segmented-btn is-active" data-motion-provider="browser_camera" aria-pressed="true">浏览器摄像头</button>
|
||||
<button type="button" class="earth-settings-segmented-btn" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
|
||||
<div class="earth-settings-slider-row">
|
||||
<input
|
||||
id="default-earth-size-slider"
|
||||
class="earth-settings-slider"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.01"
|
||||
value="1"
|
||||
aria-label="调整地球默认大小"
|
||||
>
|
||||
<span id="default-earth-size-value" class="earth-settings-slider-value">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">地形透明度</span>
|
||||
<span class="earth-settings-item-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||
</div>
|
||||
<div class="earth-settings-slider-row">
|
||||
<input
|
||||
id="terrain-opacity-slider"
|
||||
class="earth-settings-slider"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value="0.62"
|
||||
aria-label="调整地形透明度"
|
||||
>
|
||||
<span id="terrain-opacity-value" class="earth-settings-slider-value">62%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title" id="boundary-precision-status" data-boundary-precision-status>国界精度</span>
|
||||
<span class="earth-settings-item-subtitle" id="boundary-precision-detail" data-boundary-precision-detail>正在读取高清国界状态...</span>
|
||||
</div>
|
||||
<div id="boundary-precision-progress-wrap" class="earth-boundary-progress" data-boundary-precision-progress-wrap hidden>
|
||||
<div class="earth-boundary-progress__track">
|
||||
<div id="boundary-precision-progress-bar" class="earth-boundary-progress__bar" data-boundary-precision-progress-bar></div>
|
||||
</div>
|
||||
<span id="boundary-precision-progress-value" class="earth-boundary-progress__value" data-boundary-precision-progress-value>0%</span>
|
||||
</div>
|
||||
<div class="earth-settings-boundary-action-row">
|
||||
<div class="earth-settings-segmented earth-settings-segmented--boundary" role="group" aria-label="选择国界精度">
|
||||
<button id="boundary-precision-disable" type="button" class="earth-settings-segmented-btn is-active" data-boundary-precision-disable aria-pressed="true">低精</button>
|
||||
<button id="boundary-precision-build" type="button" class="earth-settings-segmented-btn" data-boundary-precision-build aria-pressed="false">高精</button>
|
||||
</div>
|
||||
<button id="boundary-precision-rebuild" type="button" class="earth-settings-reload-action" data-boundary-precision-rebuild aria-label="重新获取并构建高清国界" title="重新获取并构建" hidden disabled>
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section" data-settings-tab-panel="panels" hidden>
|
||||
<div class="earth-settings-section-title">面板</div>
|
||||
<div class="earth-settings-list">
|
||||
<label class="earth-settings-item" for="toggle-view-layers">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">图层控制</span>
|
||||
@@ -1369,55 +1502,54 @@
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">视图</div>
|
||||
<section class="earth-settings-section" data-settings-tab-panel="motion" hidden>
|
||||
<div class="earth-settings-section-title">动捕</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<label
|
||||
class="earth-settings-item"
|
||||
for="toggle-motion-debug"
|
||||
data-gatekeeper-permission="earth.motion_debug"
|
||||
>
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">地球默认大小</span>
|
||||
<span class="earth-settings-item-subtitle">用于重置视角、缩放重置和巡航视图的默认缩放比例</span>
|
||||
<span class="earth-settings-item-title">动捕调试模式</span>
|
||||
<span class="earth-settings-item-subtitle">打开骨架连线面板;未匹配为红线,匹配动作后变绿</span>
|
||||
</div>
|
||||
<div class="earth-settings-slider-row">
|
||||
<input
|
||||
id="default-earth-size-slider"
|
||||
class="earth-settings-slider"
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="5"
|
||||
step="0.01"
|
||||
value="1"
|
||||
aria-label="调整地球默认大小"
|
||||
>
|
||||
<span id="default-earth-size-value" class="earth-settings-slider-value">100%</span>
|
||||
<span class="earth-settings-switch">
|
||||
<input id="toggle-motion-debug" type="checkbox" data-motion-debug-toggle>
|
||||
<span class="earth-settings-switch-track"></span>
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
class="earth-settings-item earth-settings-item--stacked"
|
||||
data-gatekeeper-permission="earth.motion_debug"
|
||||
>
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">动捕输入源</span>
|
||||
<span class="earth-settings-item-subtitle">浏览器摄像头适合网页/SaaS;Motion Agent 适合双摄、RTSP/HTTP 和客户端</span>
|
||||
</div>
|
||||
<div class="earth-settings-segmented" role="group" aria-label="选择动捕输入源">
|
||||
<button type="button" class="earth-settings-segmented-btn is-active" data-motion-provider="browser_camera" aria-pressed="true">浏览器摄像头</button>
|
||||
<button type="button" class="earth-settings-segmented-btn" data-motion-provider="motion_agent" aria-pressed="false">Motion Agent</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">地形</div>
|
||||
<section class="earth-settings-section" data-settings-tab-panel="shortcuts" hidden>
|
||||
<div class="earth-settings-section-title">快捷键</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">地形透明度</span>
|
||||
<span class="earth-settings-item-subtitle">调高后会呈现更明显的绿色地形覆盖效果</span>
|
||||
<span class="earth-settings-item-title">键盘控制</span>
|
||||
<span class="earth-settings-item-subtitle">点击按键后按下新的快捷键;Esc 取消录入,冲突快捷键不会保存</span>
|
||||
</div>
|
||||
<div class="earth-settings-slider-row">
|
||||
<input
|
||||
id="terrain-opacity-slider"
|
||||
class="earth-settings-slider"
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value="0.62"
|
||||
aria-label="调整地形透明度"
|
||||
>
|
||||
<span id="terrain-opacity-value" class="earth-settings-slider-value">62%</span>
|
||||
<div class="earth-shortcut-list" data-shortcut-list></div>
|
||||
<div class="earth-settings-shortcut-actions">
|
||||
<button class="earth-settings-reload-action earth-settings-reload-action--text" type="button" data-shortcut-reset-all>恢复默认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<section class="earth-settings-section" data-settings-tab-panel="system" hidden>
|
||||
<div class="earth-settings-section-title">系统</div>
|
||||
<div class="earth-settings-list">
|
||||
<a
|
||||
|
||||
@@ -1,57 +1,110 @@
|
||||
const DEFAULT_BRAND_LANGUAGE = "zh";
|
||||
const EARTH_BRAND_API = "/api/v1/earth/brand";
|
||||
|
||||
const BRANDS = {
|
||||
zh: {
|
||||
ariaLabel: "智能星球计划品牌标识",
|
||||
titleAlt: "智能星球计划",
|
||||
titleSrc: "assets/brand/title-zh.png",
|
||||
logoSrc: "/earth/assets/brand/earth-logo.png",
|
||||
titleSrc: "/earth/assets/brand/title-zh.png",
|
||||
titleText: "智能星球计划",
|
||||
subtitle: "现实层宇宙全息感知系统",
|
||||
description: "卫星 · 海底光缆 · 算力基础设施",
|
||||
},
|
||||
en: {
|
||||
ariaLabel: "Intelligent Planet Program brand banner",
|
||||
titleAlt: "Intelligent Planet Program",
|
||||
titleSrc: "assets/brand/title-en.png",
|
||||
logoSrc: "/earth/assets/brand/earth-logo.png",
|
||||
titleSrc: "/earth/assets/brand/title-en.png",
|
||||
titleText: "Intelligent Planet Program",
|
||||
subtitle: "Physical-Universe Holography",
|
||||
description: "Satellites · Cables · Compute Infra",
|
||||
},
|
||||
};
|
||||
|
||||
function getBrandConfig(variant = DEFAULT_BRAND_LANGUAGE) {
|
||||
export function getDefaultBrandConfig(variant = DEFAULT_BRAND_LANGUAGE) {
|
||||
return BRANDS[variant] ?? BRANDS[DEFAULT_BRAND_LANGUAGE];
|
||||
}
|
||||
|
||||
export function renderBrand(variant = DEFAULT_BRAND_LANGUAGE) {
|
||||
const config = getBrandConfig(variant);
|
||||
function escapeHtml(value = "") {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function normalizeBrandConfig(config = {}, variant = DEFAULT_BRAND_LANGUAGE) {
|
||||
const defaults = getDefaultBrandConfig(variant);
|
||||
const normalized = {
|
||||
...defaults,
|
||||
...config,
|
||||
ariaLabel: config.aria_label ?? config.ariaLabel ?? defaults.ariaLabel,
|
||||
titleAlt: config.title_alt ?? config.titleAlt ?? defaults.titleAlt,
|
||||
logoSrc: config.logo_src ?? config.logoSrc ?? defaults.logoSrc,
|
||||
titleSrc: config.title_src ?? config.titleSrc ?? defaults.titleSrc,
|
||||
titleText: config.title_text ?? config.titleText ?? defaults.titleText,
|
||||
};
|
||||
|
||||
if (!normalized.titleText) normalized.titleText = defaults.titleText;
|
||||
if (!normalized.ariaLabel) normalized.ariaLabel = normalized.titleText;
|
||||
if (!normalized.titleAlt) normalized.titleAlt = normalized.titleText;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function renderBrand(configOrVariant = DEFAULT_BRAND_LANGUAGE) {
|
||||
const variant =
|
||||
typeof configOrVariant === "string"
|
||||
? configOrVariant
|
||||
: configOrVariant.variant ?? DEFAULT_BRAND_LANGUAGE;
|
||||
const config =
|
||||
typeof configOrVariant === "string"
|
||||
? normalizeBrandConfig({}, variant)
|
||||
: normalizeBrandConfig(configOrVariant, variant);
|
||||
const titleMarkup = config.titleSrc
|
||||
? `
|
||||
<img
|
||||
class="earth-brand__title"
|
||||
src="${escapeHtml(config.titleSrc)}"
|
||||
alt="${escapeHtml(config.titleAlt)}"
|
||||
fetchpriority="high"
|
||||
loading="eager"
|
||||
>
|
||||
`
|
||||
: `<div class="earth-brand__title-text">${escapeHtml(config.titleText)}</div>`;
|
||||
|
||||
return `
|
||||
<div class="earth-brand earth-brand--${variant}" aria-label="${config.ariaLabel}">
|
||||
<div class="earth-brand earth-brand--${escapeHtml(variant)}" aria-label="${escapeHtml(config.ariaLabel)}">
|
||||
<img
|
||||
class="earth-brand__logo"
|
||||
src="assets/brand/earth-logo.png"
|
||||
src="${escapeHtml(config.logoSrc)}"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
fetchpriority="high"
|
||||
loading="eager"
|
||||
>
|
||||
<div class="earth-brand__copy">
|
||||
<img
|
||||
class="earth-brand__title"
|
||||
src="${config.titleSrc}"
|
||||
alt="${config.titleAlt}"
|
||||
fetchpriority="high"
|
||||
loading="eager"
|
||||
>
|
||||
${titleMarkup}
|
||||
<div class="earth-brand__meta">
|
||||
<span class="earth-brand__subtitle">${config.subtitle}</span>
|
||||
<span class="earth-brand__description">${config.description}</span>
|
||||
<span class="earth-brand__subtitle">${escapeHtml(config.subtitle)}</span>
|
||||
<span class="earth-brand__description">${escapeHtml(config.description)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
export function mountBrand(target, variant = DEFAULT_BRAND_LANGUAGE) {
|
||||
export function mountBrand(target, configOrVariant = DEFAULT_BRAND_LANGUAGE) {
|
||||
if (!target) return;
|
||||
target.innerHTML = renderBrand(variant);
|
||||
target.innerHTML = renderBrand(configOrVariant);
|
||||
}
|
||||
|
||||
export async function fetchEarthBrandConfig() {
|
||||
const response = await fetch(EARTH_BRAND_API, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Earth brand config request failed: ${response.status}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
return payload?.brand ?? getDefaultBrandConfig();
|
||||
}
|
||||
|
||||
@@ -186,9 +186,29 @@ export const TERRAIN_CONFIG = {
|
||||
};
|
||||
|
||||
export const COUNTRY_BOUNDARY_CONFIG = {
|
||||
dataPath: new URL("../data/countries-admin0.min.geojson", import.meta.url).href,
|
||||
tileManifestPath: new URL("../data/boundaries/v1/manifest.json", import.meta.url).href,
|
||||
tileBasePath: new URL("../data/boundaries/v1/", import.meta.url).href,
|
||||
tileProvider: "auto",
|
||||
pmtilesPath: new URL("../data/boundaries/earth-boundaries-china-pov-v1.pmtiles", import.meta.url).href,
|
||||
legacyFallbackPath: new URL("../data/countries-admin0.min.geojson", import.meta.url).href,
|
||||
mvtLayerNames: {
|
||||
boundary: ["boundary_admin0", "boundary_disputed_internal", "coastline"],
|
||||
claim: ["claim_line"],
|
||||
},
|
||||
tileZoomThresholds: [
|
||||
{ minViewZoom: 5.2, tileZoom: 10 },
|
||||
{ minViewZoom: 4.6, tileZoom: 9 },
|
||||
{ minViewZoom: 4.0, tileZoom: 8 },
|
||||
{ minViewZoom: 3.4, tileZoom: 7 },
|
||||
{ minViewZoom: 2.8, tileZoom: 6 },
|
||||
{ minViewZoom: 1.6, tileZoom: 5 },
|
||||
],
|
||||
tilePrefetchRing: 1,
|
||||
tileDebounceMs: 180,
|
||||
tileCacheLimit: 150,
|
||||
lineAltitudeOffset: 0.115,
|
||||
hoverAltitudeOffset: 0.14,
|
||||
hoverAltitudeOffset: 0.115,
|
||||
hoverMissStickyMs: 160,
|
||||
lineColor: 0x7fc7ff,
|
||||
lineOpacity: 0.58,
|
||||
lineRenderOrder: 2.2,
|
||||
@@ -199,13 +219,13 @@ export const COUNTRY_BOUNDARY_CONFIG = {
|
||||
hoverGlowOpacity: 0.38,
|
||||
hoverGlowLineWidth: 3,
|
||||
hoverGlowRenderOrderOffset: 0.01,
|
||||
hoverGlowRadiusOffset: 0.04,
|
||||
hoverGlowRadiusOffset: 0,
|
||||
tintAltitudeOffset: 0.04,
|
||||
tintColor: 0x0b1830,
|
||||
tintRenderOrder: 0.2,
|
||||
landColor: 0x080f1b,
|
||||
landOpacity: 1.0,
|
||||
landAltitudeOffset: 0.08,
|
||||
landAltitudeOffset: 0.32,
|
||||
landRenderOrder: 0.86,
|
||||
landMaskWidth: 2048,
|
||||
landMaskHeight: 1024,
|
||||
@@ -492,7 +512,7 @@ export const EARTH_MATERIAL_CONFIG = {
|
||||
shininess: 12,
|
||||
emissive: 0x010609,
|
||||
opacity: 1,
|
||||
textureOverlayAltitudeOffset: 0.1,
|
||||
textureOverlayAltitudeOffset: 0.48,
|
||||
textureOverlayOpacity: 0.88,
|
||||
textureOverlayRenderOrder: 0.96,
|
||||
textureOverlaySpecular: 0x05080d,
|
||||
@@ -536,7 +556,9 @@ export const EARTH_MATERIAL_CONFIG = {
|
||||
sunDirection: { x: 1, y: 0.2, z: 0.4 },
|
||||
nightFloor: 0.24,
|
||||
dayBoost: 1.12,
|
||||
featherScale: 0.001,
|
||||
twilightWidth: 0.2,
|
||||
twilightFeatherScale: 1.0,
|
||||
twilightIntensity: 0.14,
|
||||
twilightColor: 0x4ea0ff,
|
||||
nightTintColor: 0x0b1830,
|
||||
|
||||
981
frontend/public/earth/js/controls.js
vendored
981
frontend/public/earth/js/controls.js
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
import * as THREE from "three";
|
||||
import { PMTiles } from "pmtiles";
|
||||
import { VectorTile } from "@mapbox/vector-tile";
|
||||
import Pbf from "pbf";
|
||||
import { CONFIG, COUNTRY_BOUNDARY_CONFIG } from "./constants.js";
|
||||
import { latLonToVector3 } from "./utils.js";
|
||||
import { latLonToVector3, screenToEarthCoords, vector3ToLatLon } from "./utils.js";
|
||||
|
||||
// ─── Module state ──────────────────────────────────────────────────────────────
|
||||
let _earthObj = null;
|
||||
@@ -8,21 +11,75 @@ let _features = [];
|
||||
let _landMesh = null;
|
||||
let _tintMesh = null;
|
||||
let _boundaryLines = null;
|
||||
let _coastlineLines = null;
|
||||
let _claimGroup = null;
|
||||
let _hoverGlowLines = null;
|
||||
let _hoverLines = null;
|
||||
let _hoveredFeature = null;
|
||||
let _hoveredGroupKey = null;
|
||||
let _hoverClearTimer = null;
|
||||
let _lastHoverHitAt = 0;
|
||||
let _lastHoverInfo = null;
|
||||
let _hoverGeometryCache = new Map();
|
||||
let _tileManifest = null;
|
||||
let _tileProvider = "pmtiles-mvt";
|
||||
let _boundaryProviderState = "unloaded";
|
||||
let _pmtilesArchive = null;
|
||||
let _tileCache = new Map();
|
||||
let _tileLru = [];
|
||||
let _inFlightTiles = new Map();
|
||||
let _activeTileKeys = new Set();
|
||||
let _lastTileSignature = "";
|
||||
let _tileUpdateTimer = null;
|
||||
let _visible = false;
|
||||
let _landFillEnabled = true;
|
||||
let _landFillSuppressed = false;
|
||||
let _tintEnabled = false;
|
||||
let _landTexture = null;
|
||||
let _loaded = false;
|
||||
let _loadPromise = null;
|
||||
let _tileAssetVersion = "";
|
||||
|
||||
const HIGH_PRECISION_BOUNDARIES_STORAGE_KEY = "planet.earth.boundaries.highPrecisionEnabled";
|
||||
|
||||
function canUseLocalStorage() {
|
||||
try {
|
||||
return typeof window !== "undefined" && !!window.localStorage;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getHighPrecisionBoundariesEnabled() {
|
||||
if (!canUseLocalStorage()) return false;
|
||||
return window.localStorage.getItem(HIGH_PRECISION_BOUNDARIES_STORAGE_KEY) === "true";
|
||||
}
|
||||
|
||||
export function setHighPrecisionBoundariesEnabled(enabled) {
|
||||
const nextEnabled = Boolean(enabled);
|
||||
if (canUseLocalStorage()) {
|
||||
window.localStorage.setItem(
|
||||
HIGH_PRECISION_BOUNDARIES_STORAGE_KEY,
|
||||
nextEnabled ? "true" : "false",
|
||||
);
|
||||
}
|
||||
return nextEnabled;
|
||||
}
|
||||
|
||||
const OCEAN_HEX = 0x010609;
|
||||
// ─── Equirectangular land/ocean fill texture ──────────────────────────────────
|
||||
|
||||
function configureLandMaskTexture(texture) {
|
||||
texture.wrapS = THREE.ClampToEdgeWrapping;
|
||||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.generateMipmaps = true;
|
||||
texture.anisotropy = 1;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function hexToStyle(hex) {
|
||||
return `#${hex.toString(16).padStart(6, "0")}`;
|
||||
}
|
||||
@@ -35,32 +92,14 @@ function hexToRgb(hex) {
|
||||
];
|
||||
}
|
||||
|
||||
function buildLandTexture(features) {
|
||||
const width = COUNTRY_BOUNDARY_CONFIG.landMaskWidth;
|
||||
const height = COUNTRY_BOUNDARY_CONFIG.landMaskHeight;
|
||||
|
||||
function drawLandMaskCanvas(features, width, height) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const oceanRgb = hexToRgb(OCEAN_HEX);
|
||||
|
||||
if (!ctx) {
|
||||
const oceanData = new Uint8Array(width * height * 4);
|
||||
for (let i = 0; i < oceanData.length; i += 4) {
|
||||
oceanData[i] = oceanRgb[0];
|
||||
oceanData[i + 1] = oceanRgb[1];
|
||||
oceanData[i + 2] = oceanRgb[2];
|
||||
oceanData[i + 3] = 255;
|
||||
}
|
||||
const fallbackTexture = new THREE.DataTexture(
|
||||
oceanData,
|
||||
width,
|
||||
height,
|
||||
THREE.RGBAFormat,
|
||||
);
|
||||
fallbackTexture.needsUpdate = true;
|
||||
return fallbackTexture;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ocean background
|
||||
@@ -93,21 +132,43 @@ function buildLandTexture(features) {
|
||||
}
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const tex = new THREE.DataTexture(
|
||||
new Uint8Array(imageData.data),
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function buildTextureFromCanvas(canvas) {
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
configureLandMaskTexture(tex);
|
||||
tex.flipY = true;
|
||||
tex.userData = {
|
||||
...(tex.userData || {}),
|
||||
sourceCanvas: canvas,
|
||||
};
|
||||
return tex;
|
||||
}
|
||||
|
||||
function buildFallbackLandTexture(width, height) {
|
||||
const oceanRgb = hexToRgb(OCEAN_HEX);
|
||||
const oceanData = new Uint8Array(width * height * 4);
|
||||
for (let i = 0; i < oceanData.length; i += 4) {
|
||||
oceanData[i] = oceanRgb[0];
|
||||
oceanData[i + 1] = oceanRgb[1];
|
||||
oceanData[i + 2] = oceanRgb[2];
|
||||
oceanData[i + 3] = 255;
|
||||
}
|
||||
const fallbackTexture = new THREE.DataTexture(
|
||||
oceanData,
|
||||
width,
|
||||
height,
|
||||
THREE.RGBAFormat,
|
||||
);
|
||||
tex.wrapS = THREE.ClampToEdgeWrapping;
|
||||
tex.wrapT = THREE.ClampToEdgeWrapping;
|
||||
tex.minFilter = THREE.LinearFilter;
|
||||
tex.magFilter = THREE.LinearFilter;
|
||||
tex.generateMipmaps = false;
|
||||
tex.flipY = true;
|
||||
tex.needsUpdate = true;
|
||||
return tex;
|
||||
return configureLandMaskTexture(fallbackTexture);
|
||||
}
|
||||
|
||||
function buildLandTexture(features) {
|
||||
const width = COUNTRY_BOUNDARY_CONFIG.landMaskWidth;
|
||||
const height = COUNTRY_BOUNDARY_CONFIG.landMaskHeight;
|
||||
const canvas = drawLandMaskCanvas(features, width, height);
|
||||
return canvas ? buildTextureFromCanvas(canvas) : buildFallbackLandTexture(width, height);
|
||||
}
|
||||
|
||||
// ─── Sphere mesh helpers ───────────────────────────────────────────────────────
|
||||
@@ -145,6 +206,14 @@ function makeTintMesh() {
|
||||
|
||||
// ─── Boundary line geometry ────────────────────────────────────────────────────
|
||||
|
||||
function boundaryLineRadius({ claim = false } = {}) {
|
||||
return (
|
||||
CONFIG.earthRadius +
|
||||
COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset +
|
||||
(claim ? 0.018 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
function ringToSegments(ring, radius, out) {
|
||||
const n = ring.length;
|
||||
if (n < 2) return;
|
||||
@@ -161,12 +230,16 @@ function featureToSegments(geom, radius) {
|
||||
geom.coordinates.forEach(ring => ringToSegments(ring, radius, pts));
|
||||
} else if (geom.type === "MultiPolygon") {
|
||||
geom.coordinates.forEach(poly => poly.forEach(ring => ringToSegments(ring, radius, pts)));
|
||||
} else if (geom.type === "LineString") {
|
||||
ringToSegments(geom.coordinates, radius, pts);
|
||||
} else if (geom.type === "MultiLineString") {
|
||||
geom.coordinates.forEach(line => ringToSegments(line, radius, pts));
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function buildBoundaryLines(features) {
|
||||
const r = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset;
|
||||
const r = boundaryLineRadius();
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color: COUNTRY_BOUNDARY_CONFIG.lineColor,
|
||||
transparent: true,
|
||||
@@ -178,7 +251,7 @@ function buildBoundaryLines(features) {
|
||||
const all = [];
|
||||
for (const feat of features) {
|
||||
const pts = featureToSegments(feat.geometry, r);
|
||||
all.push(...pts);
|
||||
for (const point of pts) all.push(point);
|
||||
}
|
||||
|
||||
const geo = all.length > 0
|
||||
@@ -192,6 +265,366 @@ function buildBoundaryLines(features) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
function isStandaloneCoastlineFeature(feature) {
|
||||
const properties = feature?.properties || {};
|
||||
return (
|
||||
properties.PLANET_LAYER === "coastline" ||
|
||||
properties.featurecla === "Coastline"
|
||||
);
|
||||
}
|
||||
|
||||
function buildClaimLines() {
|
||||
const mat = new THREE.LineDashedMaterial({
|
||||
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
|
||||
transparent: true,
|
||||
opacity: 0.82,
|
||||
dashSize: 0.7,
|
||||
gapSize: 0.42,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const lines = new THREE.LineSegments(new THREE.BufferGeometry(), mat);
|
||||
lines.name = "country-boundary-china-claims";
|
||||
lines.renderOrder = COUNTRY_BOUNDARY_CONFIG.lineRenderOrder + 0.02;
|
||||
lines.visible = false;
|
||||
lines.raycast = () => {};
|
||||
return lines;
|
||||
}
|
||||
|
||||
function makeBoundaryTileObject(features, { claim = false } = {}) {
|
||||
const radius = boundaryLineRadius({ claim });
|
||||
const points = featureListToSegments(features, radius);
|
||||
const geometry = makeLineGeometry(points);
|
||||
const material = claim
|
||||
? _claimGroup?.material?.clone?.() || buildClaimLines().material
|
||||
: _boundaryLines?.material?.clone?.() || new THREE.LineBasicMaterial({
|
||||
color: COUNTRY_BOUNDARY_CONFIG.lineColor,
|
||||
transparent: true,
|
||||
opacity: COUNTRY_BOUNDARY_CONFIG.lineOpacity,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const lines = new THREE.LineSegments(geometry, material);
|
||||
lines.name = claim ? "country-boundary-claim-tile" : "country-boundary-tile";
|
||||
lines.renderOrder = claim
|
||||
? COUNTRY_BOUNDARY_CONFIG.lineRenderOrder + 0.02
|
||||
: COUNTRY_BOUNDARY_CONFIG.lineRenderOrder + 0.01;
|
||||
lines.visible = _visible;
|
||||
lines.raycast = () => {};
|
||||
if (claim && typeof lines.computeLineDistances === "function") {
|
||||
lines.computeLineDistances();
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function disposeTileObject(object) {
|
||||
if (!object) return;
|
||||
if (_earthObj) _earthObj.remove(object);
|
||||
object.geometry?.dispose?.();
|
||||
object.material?.dispose?.();
|
||||
}
|
||||
|
||||
function touchTileKey(key) {
|
||||
_tileLru = _tileLru.filter(item => item !== key);
|
||||
_tileLru.push(key);
|
||||
}
|
||||
|
||||
function trimTileCache() {
|
||||
const limit = COUNTRY_BOUNDARY_CONFIG.tileCacheLimit;
|
||||
while (_tileLru.length > limit) {
|
||||
const key = _tileLru.shift();
|
||||
if (!key || _activeTileKeys.has(key)) continue;
|
||||
const cached = _tileCache.get(key);
|
||||
_tileCache.delete(key);
|
||||
disposeTileObject(cached?.object);
|
||||
}
|
||||
}
|
||||
|
||||
function setTileObjectVisible(object, visible) {
|
||||
if (object) object.visible = _visible && visible;
|
||||
}
|
||||
|
||||
function setActiveTileKeys(nextKeys) {
|
||||
_activeTileKeys = new Set(nextKeys);
|
||||
_tileCache.forEach((entry, key) => {
|
||||
setTileObjectVisible(entry.object, _activeTileKeys.has(key));
|
||||
});
|
||||
}
|
||||
|
||||
function tileUrlForKey(key) {
|
||||
const [kind, z, x, y] = key.split("/");
|
||||
const prefix = COUNTRY_BOUNDARY_CONFIG.tileBasePath.replace(/\/?$/, "/");
|
||||
const versionSuffix = _tileAssetVersion ? `?v=${encodeURIComponent(_tileAssetVersion)}` : "";
|
||||
if (kind === "claim") {
|
||||
return `${prefix}china-claims/${z}/${x}/${y}.geojson${versionSuffix}`;
|
||||
}
|
||||
return `${prefix}${z}/${x}/${y}.geojson${versionSuffix}`;
|
||||
}
|
||||
|
||||
function versionedBoundaryAssetUrl(path) {
|
||||
const url = new URL(path, COUNTRY_BOUNDARY_CONFIG.tileBasePath);
|
||||
if (_tileAssetVersion) url.searchParams.set("v", _tileAssetVersion);
|
||||
return url.href;
|
||||
}
|
||||
|
||||
function manifestProvider(manifest) {
|
||||
const configured = COUNTRY_BOUNDARY_CONFIG.tileProvider;
|
||||
if (configured && configured !== "auto") return configured;
|
||||
return manifest?.tileProvider || manifest?.format || "pmtiles-mvt";
|
||||
}
|
||||
|
||||
async function fetchJsonAsset(url, { required = false } = {}) {
|
||||
const resp = await fetch(url, { cache: "no-store" });
|
||||
if (!resp.ok) {
|
||||
if (required) throw new Error(`${url} HTTP ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (err) {
|
||||
if (required) throw err;
|
||||
console.warn("[country-boundaries] JSON asset unavailable", url, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function manifestPmtilesUrl(manifest) {
|
||||
const fromManifest =
|
||||
manifest?.pmtiles?.url ||
|
||||
manifest?.pmtiles?.path ||
|
||||
manifest?.artifacts?.pmtiles?.url ||
|
||||
manifest?.artifacts?.pmtiles?.path ||
|
||||
manifest?.artifact;
|
||||
if (!fromManifest) return COUNTRY_BOUNDARY_CONFIG.pmtilesPath;
|
||||
return new URL(fromManifest, COUNTRY_BOUNDARY_CONFIG.tileBasePath).href;
|
||||
}
|
||||
|
||||
function ensurePmtilesArchive() {
|
||||
if (_pmtilesArchive) return _pmtilesArchive;
|
||||
const url = manifestPmtilesUrl(_tileManifest);
|
||||
_pmtilesArchive = new PMTiles(url);
|
||||
return _pmtilesArchive;
|
||||
}
|
||||
|
||||
function getMvtLayerNames(kind) {
|
||||
const layerConfig = COUNTRY_BOUNDARY_CONFIG.mvtLayerNames || {};
|
||||
if (kind === "claim") return layerConfig.claim || ["claim_line"];
|
||||
return layerConfig.boundary || ["boundary_admin0", "boundary_disputed_internal", "coastline"];
|
||||
}
|
||||
|
||||
async function loadPmtilesMvtFeatures(key) {
|
||||
const [kind, zText, xText, yText] = key.split("/");
|
||||
const z = Number(zText);
|
||||
const x = Number(xText);
|
||||
const y = Number(yText);
|
||||
if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) return [];
|
||||
|
||||
const archive = ensurePmtilesArchive();
|
||||
const tile = await archive.getZxy(z, x, y);
|
||||
if (!tile?.data) return [];
|
||||
|
||||
const vectorTile = new VectorTile(new Pbf(new Uint8Array(tile.data)));
|
||||
const features = [];
|
||||
for (const layerName of getMvtLayerNames(kind)) {
|
||||
const layer = vectorTile.layers[layerName];
|
||||
if (!layer) continue;
|
||||
for (let i = 0; i < layer.length; i++) {
|
||||
const feature = layer.feature(i).toGeoJSON(x, y, z);
|
||||
if (feature?.geometry) features.push(feature);
|
||||
}
|
||||
}
|
||||
return features;
|
||||
}
|
||||
|
||||
async function loadDebugGeojsonFeatures(key) {
|
||||
const resp = await fetch(tileUrlForKey(key));
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 404) return [];
|
||||
throw new Error(`boundary tile ${key} HTTP ${resp.status}`);
|
||||
}
|
||||
const payload = await resp.json();
|
||||
return (payload.features || []).filter(f => f.geometry);
|
||||
}
|
||||
|
||||
async function loadBoundaryTile(key) {
|
||||
if (_tileCache.has(key)) {
|
||||
touchTileKey(key);
|
||||
return _tileCache.get(key);
|
||||
}
|
||||
if (_inFlightTiles.has(key)) return _inFlightTiles.get(key);
|
||||
|
||||
const promise = (async () => {
|
||||
if (_tileProvider !== "pmtiles-mvt") {
|
||||
throw new Error(`不支持的国界瓦片 provider: ${_tileProvider}`);
|
||||
}
|
||||
const features = await loadPmtilesMvtFeatures(key);
|
||||
if (features.length === 0) return null;
|
||||
const entry = {
|
||||
object: makeBoundaryTileObject(features, { claim: key.startsWith("claim/") }),
|
||||
};
|
||||
_earthObj.add(entry.object);
|
||||
_tileCache.set(key, entry);
|
||||
touchTileKey(key);
|
||||
trimTileCache();
|
||||
return entry;
|
||||
})().catch(err => {
|
||||
console.warn("[country-boundaries] tile load failed", key, err);
|
||||
return null;
|
||||
}).finally(() => {
|
||||
_inFlightTiles.delete(key);
|
||||
});
|
||||
|
||||
_inFlightTiles.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
function lonToTileX(lon, zoom) {
|
||||
const n = 2 ** zoom;
|
||||
return Math.max(0, Math.min(n - 1, Math.floor(((lon + 180) / 360) * n)));
|
||||
}
|
||||
|
||||
function latToTileY(lat, zoom) {
|
||||
const n = 2 ** zoom;
|
||||
const clamped = Math.max(-85.05112878, Math.min(85.05112878, lat));
|
||||
const rad = clamped * Math.PI / 180;
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(n - 1, Math.floor((1 - Math.asinh(Math.tan(rad)) / Math.PI) / 2 * n)),
|
||||
);
|
||||
}
|
||||
|
||||
function tileZoomForViewZoom(viewZoom) {
|
||||
const thresholds = COUNTRY_BOUNDARY_CONFIG.tileZoomThresholds || [];
|
||||
const maxZoom = _tileManifest?.tiles?.maxZoom ?? 0;
|
||||
for (const threshold of thresholds) {
|
||||
if (viewZoom >= threshold.minViewZoom) {
|
||||
return Math.min(threshold.tileZoom, maxZoom);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bboxFromVisibleEarth(camera, renderer, earth) {
|
||||
if (!camera || !renderer?.domElement || !earth) return null;
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return null;
|
||||
|
||||
const samples = [
|
||||
[rect.left + rect.width * 0.5, rect.top + rect.height * 0.5],
|
||||
[rect.left, rect.top],
|
||||
[rect.right, rect.top],
|
||||
[rect.left, rect.bottom],
|
||||
[rect.right, rect.bottom],
|
||||
[rect.left + rect.width * 0.5, rect.top],
|
||||
[rect.left + rect.width * 0.5, rect.bottom],
|
||||
[rect.left, rect.top + rect.height * 0.5],
|
||||
[rect.right, rect.top + rect.height * 0.5],
|
||||
];
|
||||
const coords = [];
|
||||
for (const [x, y] of samples) {
|
||||
const point = screenToEarthCoords(x, y, camera, earth, renderer.domElement);
|
||||
if (!point) continue;
|
||||
coords.push(vector3ToLatLon(point));
|
||||
}
|
||||
if (coords.length === 0) return null;
|
||||
|
||||
const lats = coords.map(coord => coord.lat);
|
||||
const lons = coords.map(coord => coord.lon);
|
||||
const latMin = Math.max(-85.05112878, Math.min(...lats));
|
||||
const latMax = Math.min(85.05112878, Math.max(...lats));
|
||||
const latPad = Math.max(2, (latMax - latMin) * 0.18);
|
||||
const rawLonMin = Math.max(-180, Math.min(...lons));
|
||||
const rawLonMax = Math.min(180, Math.max(...lons));
|
||||
const rawLonSpan = rawLonMax - rawLonMin;
|
||||
|
||||
if (rawLonSpan > 180) {
|
||||
const shifted = lons.map(lon => lon < 0 ? lon + 360 : lon);
|
||||
const shiftedMin = Math.min(...shifted);
|
||||
const shiftedMax = Math.max(...shifted);
|
||||
const shiftedPad = Math.max(2, (shiftedMax - shiftedMin) * 0.18);
|
||||
const west = shiftedMin - shiftedPad;
|
||||
const east = shiftedMax + shiftedPad;
|
||||
const ranges = [];
|
||||
if (west < 180) ranges.push({ west: Math.max(-180, west), east: 180 });
|
||||
if (east > 180) ranges.push({ west: -180, east: Math.min(180, east - 360) });
|
||||
return {
|
||||
south: Math.max(-85.05112878, latMin - latPad),
|
||||
north: Math.min(85.05112878, latMax + latPad),
|
||||
ranges: ranges.length > 0 ? ranges : [{ west: -180, east: 180 }],
|
||||
};
|
||||
}
|
||||
|
||||
const lonPad = Math.max(2, rawLonSpan * 0.18);
|
||||
return {
|
||||
west: Math.max(-180, rawLonMin - lonPad),
|
||||
south: Math.max(-85.05112878, latMin - latPad),
|
||||
east: Math.min(180, rawLonMax + lonPad),
|
||||
north: Math.min(85.05112878, latMax + latPad),
|
||||
};
|
||||
}
|
||||
|
||||
function tileKeysForBbox(bbox, zoom, { claim = false } = {}) {
|
||||
if (Array.isArray(bbox.ranges)) {
|
||||
return bbox.ranges.flatMap(range =>
|
||||
tileKeysForBbox({ ...bbox, west: range.west, east: range.east, ranges: null }, zoom, { claim }),
|
||||
);
|
||||
}
|
||||
|
||||
const prefetch = COUNTRY_BOUNDARY_CONFIG.tilePrefetchRing;
|
||||
const n = 2 ** zoom;
|
||||
const xMin = lonToTileX(bbox.west, zoom);
|
||||
const xMax = lonToTileX(bbox.east, zoom);
|
||||
const yMin = latToTileY(bbox.north, zoom);
|
||||
const yMax = latToTileY(bbox.south, zoom);
|
||||
const keys = [];
|
||||
for (let x = Math.max(0, xMin - prefetch); x <= Math.min(n - 1, xMax + prefetch); x++) {
|
||||
for (let y = Math.max(0, yMin - prefetch); y <= Math.min(n - 1, yMax + prefetch); y++) {
|
||||
keys.push(`${claim ? "claim" : "boundary"}/${zoom}/${x}/${y}`);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
async function refreshBoundaryTiles({ camera, renderer, earth, viewZoom }) {
|
||||
if (!_loaded || !_visible || !_tileManifest) return;
|
||||
if (_tileProvider !== "pmtiles-mvt") return;
|
||||
const tileZoom = tileZoomForViewZoom(viewZoom);
|
||||
if (!tileZoom) {
|
||||
_lastTileSignature = "";
|
||||
setActiveTileKeys([]);
|
||||
updateBoundaryLineDimState();
|
||||
return;
|
||||
}
|
||||
|
||||
const bbox = bboxFromVisibleEarth(camera, renderer, earth);
|
||||
if (!bbox) return;
|
||||
const keys = tileKeysForBbox(bbox, tileZoom);
|
||||
if (_tileManifest?.chinaClaims?.available) {
|
||||
keys.push(...tileKeysForBbox(bbox, tileZoom, { claim: true }));
|
||||
}
|
||||
const signature = keys.slice().sort().join("|");
|
||||
if (signature === _lastTileSignature) return;
|
||||
_lastTileSignature = signature;
|
||||
setActiveTileKeys(keys);
|
||||
updateBoundaryLineDimState();
|
||||
|
||||
await Promise.all(keys.map(async key => {
|
||||
const entry = await loadBoundaryTile(key);
|
||||
if (!entry) return;
|
||||
entry.object.userData.tileKey = key;
|
||||
setTileObjectVisible(entry.object, _activeTileKeys.has(key));
|
||||
}));
|
||||
}
|
||||
|
||||
export function updateCountryBoundaryTiles(context = {}) {
|
||||
if (_tileUpdateTimer) return;
|
||||
_tileUpdateTimer = setTimeout(() => {
|
||||
_tileUpdateTimer = null;
|
||||
refreshBoundaryTiles(context);
|
||||
}, COUNTRY_BOUNDARY_CONFIG.tileDebounceMs);
|
||||
}
|
||||
|
||||
function buildHoverLines() {
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color: COUNTRY_BOUNDARY_CONFIG.hoverLineColor,
|
||||
@@ -236,6 +669,15 @@ function setBoundaryLinesDimmed(dimmed) {
|
||||
_boundaryLines.material.needsUpdate = true;
|
||||
}
|
||||
|
||||
function updateBoundaryLineDimState() {
|
||||
if (_coastlineLines?.material) {
|
||||
_coastlineLines.material.opacity = COUNTRY_BOUNDARY_CONFIG.lineOpacity;
|
||||
_coastlineLines.material.needsUpdate = true;
|
||||
_coastlineLines.visible = _visible && !_hoveredFeature;
|
||||
}
|
||||
setBoundaryLinesDimmed(Boolean(_hoveredFeature) || _activeTileKeys.size > 0);
|
||||
}
|
||||
|
||||
function setHoverLinesVisible(visible) {
|
||||
const nextVisible = _visible && Boolean(visible);
|
||||
if (_hoverGlowLines) _hoverGlowLines.visible = nextVisible;
|
||||
@@ -243,7 +685,12 @@ function setHoverLinesVisible(visible) {
|
||||
}
|
||||
|
||||
function featureListToSegments(features, radius) {
|
||||
return features.flatMap(f => featureToSegments(f.geometry, radius));
|
||||
const all = [];
|
||||
for (const feature of features) {
|
||||
const points = featureToSegments(feature.geometry, radius);
|
||||
for (const point of points) all.push(point);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
function makeLineGeometry(points) {
|
||||
@@ -265,12 +712,26 @@ function setLineGeometry(line, geometry) {
|
||||
line.geometry = geometry;
|
||||
}
|
||||
|
||||
function cancelPendingHoverClear() {
|
||||
if (!_hoverClearTimer) return;
|
||||
clearTimeout(_hoverClearTimer);
|
||||
_hoverClearTimer = null;
|
||||
}
|
||||
|
||||
function scheduleHoverClear(delayMs) {
|
||||
if (_hoverClearTimer) return;
|
||||
_hoverClearTimer = setTimeout(() => {
|
||||
_hoverClearTimer = null;
|
||||
clearCountryBoundaryHover({ cancelSticky: false });
|
||||
}, Math.max(0, delayMs));
|
||||
}
|
||||
|
||||
function getHoverGeometries(groupKey, features) {
|
||||
const cacheKey = groupKey || features[0] || "__empty__";
|
||||
const cached = _hoverGeometryCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const coreRadius = CONFIG.earthRadius + COUNTRY_BOUNDARY_CONFIG.hoverAltitudeOffset;
|
||||
const coreRadius = boundaryLineRadius();
|
||||
const glowRadius = coreRadius + COUNTRY_BOUNDARY_CONFIG.hoverGlowRadiusOffset;
|
||||
const geometries = {
|
||||
core: markCachedHoverGeometry(
|
||||
@@ -357,6 +818,8 @@ export function createCountryBoundaryLayer(earthObj) {
|
||||
_earthObj = earthObj;
|
||||
_tintMesh = makeTintMesh();
|
||||
_earthObj.add(_tintMesh);
|
||||
_claimGroup = buildClaimLines();
|
||||
_earthObj.add(_claimGroup);
|
||||
}
|
||||
|
||||
/** Fetch GeoJSON, build meshes. Idempotent; safe to call multiple times. */
|
||||
@@ -365,18 +828,86 @@ export async function loadCountryBoundaries() {
|
||||
if (_loadPromise) return _loadPromise;
|
||||
|
||||
_loadPromise = (async () => {
|
||||
const resp = await fetch(COUNTRY_BOUNDARY_CONFIG.dataPath);
|
||||
if (!resp.ok) throw new Error(`国界数据加载失败 HTTP ${resp.status}`);
|
||||
const geojson = await resp.json();
|
||||
let geojson = { type: "FeatureCollection", features: [] };
|
||||
let baseGeojson = null;
|
||||
let claimGeojson = null;
|
||||
const highPrecisionEnabled = getHighPrecisionBoundariesEnabled();
|
||||
const manifest = highPrecisionEnabled
|
||||
? await fetchJsonAsset(COUNTRY_BOUNDARY_CONFIG.tileManifestPath)
|
||||
: null;
|
||||
if (manifest) {
|
||||
const provider = manifestProvider(manifest);
|
||||
const pmtilesUrl = manifestPmtilesUrl(manifest);
|
||||
const pmtilesResp = provider === "pmtiles-mvt"
|
||||
? await fetch(pmtilesUrl, { method: "HEAD", cache: "no-store" })
|
||||
: null;
|
||||
const highPrecisionReady = (
|
||||
["pmtiles-mvt", "geojson-high-precision"].includes(provider) &&
|
||||
(provider !== "pmtiles-mvt" || pmtilesResp?.ok)
|
||||
);
|
||||
if (highPrecisionReady) {
|
||||
_tileManifest = manifest;
|
||||
_tileProvider = provider;
|
||||
_boundaryProviderState = provider;
|
||||
_tileAssetVersion = [
|
||||
_tileManifest.version,
|
||||
_tileManifest.builtAt,
|
||||
_tileManifest.sourceFeatureCount,
|
||||
_tileManifest.pmtiles?.sha256,
|
||||
].filter(Boolean).join("-");
|
||||
const basePath = _tileManifest.base || _tileManifest.baseGeojson;
|
||||
const hoverPath = _tileManifest.hoverIndex || _tileManifest.hoverIndexGeojson;
|
||||
if (basePath) baseGeojson = await fetchJsonAsset(versionedBoundaryAssetUrl(basePath));
|
||||
if (hoverPath) {
|
||||
geojson = await fetchJsonAsset(versionedBoundaryAssetUrl(hoverPath)) || geojson;
|
||||
}
|
||||
const claimPath = _tileManifest.claimLine || _tileManifest.chinaClaims?.path;
|
||||
if (claimPath) claimGeojson = await fetchJsonAsset(versionedBoundaryAssetUrl(claimPath));
|
||||
}
|
||||
}
|
||||
if (_tileManifest && !(geojson.features || []).length) {
|
||||
console.warn("[country-boundaries] high precision hover index unavailable; using legacy fallback");
|
||||
_tileManifest = null;
|
||||
_pmtilesArchive = null;
|
||||
claimGeojson = null;
|
||||
}
|
||||
if (!_tileManifest) {
|
||||
geojson = await fetchJsonAsset(COUNTRY_BOUNDARY_CONFIG.legacyFallbackPath, { required: true });
|
||||
baseGeojson = geojson;
|
||||
_tileProvider = "legacy-geojson";
|
||||
_boundaryProviderState = "legacy-geojson";
|
||||
_tileAssetVersion = "legacy";
|
||||
}
|
||||
_features = (geojson.features || []).filter(f => f.geometry);
|
||||
const baseFeatures = (baseGeojson?.features || _features).filter(f => f.geometry);
|
||||
const boundaryBaseFeatures = baseFeatures.filter(
|
||||
feature => !isStandaloneCoastlineFeature(feature),
|
||||
);
|
||||
const coastlineFeatures = baseFeatures.filter(isStandaloneCoastlineFeature);
|
||||
|
||||
const tex = buildLandTexture(_features);
|
||||
_landMesh = makeLandMesh(tex);
|
||||
_landTexture = buildLandTexture(_features);
|
||||
_landMesh = makeLandMesh(_landTexture);
|
||||
_earthObj.add(_landMesh);
|
||||
|
||||
_boundaryLines = buildBoundaryLines(_features);
|
||||
_boundaryLines = buildBoundaryLines(boundaryBaseFeatures);
|
||||
_earthObj.add(_boundaryLines);
|
||||
|
||||
_coastlineLines = buildBoundaryLines(coastlineFeatures);
|
||||
_coastlineLines.name = "country-coastline-all";
|
||||
_earthObj.add(_coastlineLines);
|
||||
|
||||
if (claimGeojson?.features?.length && _claimGroup) {
|
||||
const claimPoints = featureListToSegments(
|
||||
claimGeojson.features,
|
||||
boundaryLineRadius({ claim: true }),
|
||||
);
|
||||
_claimGroup.geometry?.dispose();
|
||||
_claimGroup.geometry = makeLineGeometry(claimPoints);
|
||||
if (typeof _claimGroup.computeLineDistances === "function") {
|
||||
_claimGroup.computeLineDistances();
|
||||
}
|
||||
}
|
||||
|
||||
_hoverGlowLines = buildHoverGlowLines();
|
||||
_earthObj.add(_hoverGlowLines);
|
||||
|
||||
@@ -421,13 +952,22 @@ export function toggleCountryBoundaries(
|
||||
_landMesh.visible = _landFillEnabled && !_landFillSuppressed;
|
||||
}
|
||||
if (_boundaryLines) _boundaryLines.visible = _visible;
|
||||
if (_coastlineLines) _coastlineLines.visible = _visible && !_hoveredFeature;
|
||||
_tileCache.forEach((entry, key) => {
|
||||
setTileObjectVisible(entry.object, _visible && _activeTileKeys.has(key));
|
||||
});
|
||||
if (_claimGroup) _claimGroup.visible = _visible && Boolean(_tileManifest?.chinaClaims?.available || _tileManifest?.claimLine);
|
||||
setHoverLinesVisible(_hoveredFeature);
|
||||
|
||||
if (!_visible) {
|
||||
cancelPendingHoverClear();
|
||||
_hoveredFeature = null;
|
||||
_hoveredGroupKey = null;
|
||||
setBoundaryLinesDimmed(false);
|
||||
_lastHoverInfo = null;
|
||||
setHoverLinesVisible(false);
|
||||
setActiveTileKeys([]);
|
||||
_lastTileSignature = "";
|
||||
updateBoundaryLineDimState();
|
||||
}
|
||||
|
||||
if (_tintMesh) _tintMesh.visible = _visible && showTint && _tintEnabled;
|
||||
@@ -458,12 +998,18 @@ export function getShowCountryBoundaries() {
|
||||
return _visible;
|
||||
}
|
||||
|
||||
export function getCountryBoundaryProviderState() {
|
||||
return _boundaryProviderState;
|
||||
}
|
||||
|
||||
/** Clear the hover highlight without hiding the full layer. */
|
||||
export function clearCountryBoundaryHover() {
|
||||
if (!_hoveredFeature) return;
|
||||
export function clearCountryBoundaryHover({ cancelSticky = true } = {}) {
|
||||
if (cancelSticky) cancelPendingHoverClear();
|
||||
if (!_hoveredFeature && !_lastHoverInfo) return;
|
||||
_hoveredFeature = null;
|
||||
_hoveredGroupKey = null;
|
||||
setBoundaryLinesDimmed(false);
|
||||
_lastHoverInfo = null;
|
||||
updateBoundaryLineDimState();
|
||||
setHoverLinesVisible(false);
|
||||
}
|
||||
|
||||
@@ -478,15 +1024,30 @@ export function updateCountryBoundaryHover(coords) {
|
||||
const found = _features.find(f => featureContains(lat, lon, f)) || null;
|
||||
const groupKey = getCountryHighlightGroupKey(found);
|
||||
|
||||
if (!found && _hoveredFeature) {
|
||||
const stickyMs = Math.max(0, COUNTRY_BOUNDARY_CONFIG.hoverMissStickyMs || 0);
|
||||
const elapsedMs = Date.now() - _lastHoverHitAt;
|
||||
if (stickyMs > 0 && elapsedMs < stickyMs) {
|
||||
scheduleHoverClear(stickyMs - elapsedMs);
|
||||
return _lastHoverInfo;
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
cancelPendingHoverClear();
|
||||
_lastHoverHitAt = Date.now();
|
||||
_lastHoverInfo = makeCountryInfo(found);
|
||||
}
|
||||
|
||||
if (found !== _hoveredFeature || groupKey !== _hoveredGroupKey) {
|
||||
_hoveredFeature = found;
|
||||
_hoveredGroupKey = groupKey;
|
||||
if (_hoverLines) {
|
||||
if (!found) {
|
||||
setBoundaryLinesDimmed(false);
|
||||
updateBoundaryLineDimState();
|
||||
setHoverLinesVisible(false);
|
||||
} else {
|
||||
setBoundaryLinesDimmed(true);
|
||||
updateBoundaryLineDimState();
|
||||
const highlightFeatures = getHighlightFeatures(found);
|
||||
const geometries = getHoverGeometries(groupKey, highlightFeatures);
|
||||
setLineGeometry(_hoverGlowLines, geometries.glow);
|
||||
@@ -496,13 +1057,17 @@ export function updateCountryBoundaryHover(coords) {
|
||||
}
|
||||
}
|
||||
|
||||
return found ? makeCountryInfo(found) : null;
|
||||
if (!found) _lastHoverInfo = null;
|
||||
return found ? _lastHoverInfo : null;
|
||||
}
|
||||
|
||||
/** Dispose all Three.js objects and reset state. */
|
||||
export function clearCountryBoundaryData() {
|
||||
_hoveredFeature = null;
|
||||
_hoveredGroupKey = null;
|
||||
cancelPendingHoverClear();
|
||||
_lastHoverHitAt = 0;
|
||||
_lastHoverInfo = null;
|
||||
|
||||
function disposeObj(obj) {
|
||||
if (!obj) return;
|
||||
@@ -519,14 +1084,34 @@ export function clearCountryBoundaryData() {
|
||||
disposeObj(_hoverLines);
|
||||
disposeObj(_hoverGlowLines);
|
||||
disposeObj(_boundaryLines);
|
||||
disposeObj(_coastlineLines);
|
||||
disposeObj(_claimGroup);
|
||||
disposeObj(_landMesh);
|
||||
disposeObj(_tintMesh);
|
||||
_landTexture?.dispose?.();
|
||||
_tileCache.forEach(entry => disposeTileObject(entry.object));
|
||||
_tileCache.clear();
|
||||
_tileLru = [];
|
||||
_inFlightTiles.clear();
|
||||
_activeTileKeys.clear();
|
||||
_lastTileSignature = "";
|
||||
if (_tileUpdateTimer) {
|
||||
clearTimeout(_tileUpdateTimer);
|
||||
_tileUpdateTimer = null;
|
||||
}
|
||||
|
||||
_hoverLines = null;
|
||||
_hoverGlowLines = null;
|
||||
_boundaryLines = null;
|
||||
_coastlineLines = null;
|
||||
_claimGroup = null;
|
||||
_landMesh = null;
|
||||
_tintMesh = null;
|
||||
_landTexture = null;
|
||||
_tileManifest = null;
|
||||
_tileProvider = "pmtiles-mvt";
|
||||
_boundaryProviderState = "unloaded";
|
||||
_pmtilesArchive = null;
|
||||
_features = [];
|
||||
disposeHoverGeometryCache();
|
||||
_loaded = false;
|
||||
|
||||
@@ -36,18 +36,24 @@ const _earthSunDirection = new THREE.Vector3(
|
||||
EARTH_MATERIAL_CONFIG.dayNight.sunDirection.z,
|
||||
).normalize();
|
||||
|
||||
function applyEarthDayNightShader(material) {
|
||||
function applyEarthDayNightShader(material, options = {}) {
|
||||
if (!material || !EARTH_MATERIAL_CONFIG.dayNight.enabled) return;
|
||||
|
||||
const twilightColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.twilightColor);
|
||||
const nightTintColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.nightTintColor);
|
||||
|
||||
const {
|
||||
nightFloor = EARTH_MATERIAL_CONFIG.dayNight.nightFloor,
|
||||
} = options;
|
||||
material.onBeforeCompile = (shader) => {
|
||||
_earthShaders.push(shader);
|
||||
shader.uniforms.uSunDirectionWorld = { value: _earthSunDirection.clone() };
|
||||
shader.uniforms.uNightFloor = { value: EARTH_MATERIAL_CONFIG.dayNight.nightFloor };
|
||||
shader.uniforms.uNightFloor = { value: nightFloor };
|
||||
shader.uniforms.uDayBoost = { value: EARTH_MATERIAL_CONFIG.dayNight.dayBoost };
|
||||
shader.uniforms.uFeatherScale = { value: EARTH_MATERIAL_CONFIG.dayNight.featherScale };
|
||||
shader.uniforms.uTwilightWidth = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightWidth };
|
||||
shader.uniforms.uTwilightFeatherScale = {
|
||||
value: EARTH_MATERIAL_CONFIG.dayNight.twilightFeatherScale,
|
||||
};
|
||||
shader.uniforms.uTwilightIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightIntensity };
|
||||
shader.uniforms.uTwilightColor = { value: twilightColor };
|
||||
shader.uniforms.uNightTintColor = { value: nightTintColor };
|
||||
@@ -71,7 +77,9 @@ varying vec3 vWorldNormal;
|
||||
uniform vec3 uSunDirectionWorld;
|
||||
uniform float uNightFloor;
|
||||
uniform float uDayBoost;
|
||||
uniform float uFeatherScale;
|
||||
uniform float uTwilightWidth;
|
||||
uniform float uTwilightFeatherScale;
|
||||
uniform float uTwilightIntensity;
|
||||
uniform vec3 uTwilightColor;
|
||||
uniform vec3 uNightTintColor;
|
||||
@@ -83,8 +91,9 @@ uniform float uDayNightEnabled;`,
|
||||
vec3 worldNormal = normalize(vWorldNormal);
|
||||
vec3 sunDir = normalize(uSunDirectionWorld);
|
||||
float sunFacing = dot(worldNormal, sunDir);
|
||||
float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing);
|
||||
float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing));
|
||||
float edgeFeather = max(fwidth(sunFacing) * uFeatherScale, uTwilightWidth);
|
||||
float daylight = smoothstep(-edgeFeather, edgeFeather, sunFacing);
|
||||
float twilight = 1.0 - smoothstep(0.0, edgeFeather * uTwilightFeatherScale, abs(sunFacing));
|
||||
|
||||
// Camera-facing diffuse: vNormal and vViewPosition are both in view space.
|
||||
// N·V gives 1.0 at center-facing, 0 at limb — creates depth cue regardless of earth rotation.
|
||||
@@ -170,10 +179,12 @@ export function createEarth(scene) {
|
||||
);
|
||||
const occluderMaterial = new THREE.MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
depthTest: false,
|
||||
depthWrite: true,
|
||||
side: THREE.FrontSide,
|
||||
});
|
||||
const occluder = new THREE.Mesh(occluderGeometry, occluderMaterial);
|
||||
occluder.renderOrder = -1;
|
||||
occluder.renderOrder = 0.5;
|
||||
earth.add(occluder);
|
||||
|
||||
// Keep the original atmosphere shells on the legacy camera-facing shader so
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// info-card.js - Unified info card module
|
||||
import { showStatusMessage } from './ui.js';
|
||||
import {
|
||||
getNewsDisplaySummary,
|
||||
getNewsDisplayTitle,
|
||||
} from './news-locale.js';
|
||||
|
||||
let currentType = null;
|
||||
let cardMounted = false;
|
||||
@@ -124,7 +128,7 @@ function escapeCssIdentifier(value) {
|
||||
}
|
||||
|
||||
function getNewsSummaryText(data) {
|
||||
return (data?.summary || data?.title || '').trim() || '暂无摘要';
|
||||
return getNewsDisplaySummary(data);
|
||||
}
|
||||
|
||||
function getNewsSummaryPreview(data, maxLength = 34) {
|
||||
@@ -180,12 +184,13 @@ function startTypewriterAnimation(target, text, options = {}) {
|
||||
function renderNewsCardContent(content, data) {
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const summary = getNewsSummaryText(data);
|
||||
const title = getNewsDisplayTitle(data);
|
||||
content.innerHTML = `
|
||||
<div class="info-card-news-layout">
|
||||
<div class="info-card-news-kicker">NEWS SIGNAL</div>
|
||||
<div class="info-card-news-title">${data?.title || '新闻事件'}</div>
|
||||
<div class="info-card-news-kicker">新闻信号</div>
|
||||
<div class="info-card-news-title">${escapeInfoCardHtml(title)}</div>
|
||||
<div class="info-card-news-summary-shell">
|
||||
<div class="info-card-news-summary-label">SUMMARY</div>
|
||||
<div class="info-card-news-summary-label">概要</div>
|
||||
<div class="info-card-news-summary" data-news-summary></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,12 +202,13 @@ function renderNewsCardContent(content, data) {
|
||||
function renderMobileNewsCardContent(content, data) {
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const summary = getNewsSummaryText(data);
|
||||
const title = getNewsDisplayTitle(data);
|
||||
content.innerHTML = `
|
||||
<div class="earth-mobile-news-detail">
|
||||
<div class="earth-mobile-news-detail-kicker">NEWS SIGNAL</div>
|
||||
<div class="earth-mobile-news-detail-title">${data?.title || '新闻事件'}</div>
|
||||
<div class="earth-mobile-news-detail-kicker">新闻信号</div>
|
||||
<div class="earth-mobile-news-detail-title">${escapeInfoCardHtml(title)}</div>
|
||||
<div class="earth-mobile-news-detail-summary-shell">
|
||||
<div class="earth-mobile-news-detail-summary-label">SUMMARY</div>
|
||||
<div class="earth-mobile-news-detail-summary-label">概要</div>
|
||||
<div class="earth-mobile-news-detail-summary" data-news-summary></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1026,7 +1032,7 @@ function getMobilePopupTitle(type, data) {
|
||||
case 'landing_point': return data.name || '登陆点';
|
||||
case 'satellite': return data.name || '卫星';
|
||||
case 'bgp': return data.anomaly_type || 'BGP事件';
|
||||
case 'news': return data.title || '新闻事件';
|
||||
case 'news': return getNewsDisplayTitle(data);
|
||||
case 'bgp_collector': return data.collector || 'BGP观测站';
|
||||
case 'compute_center_unresolved': return '待定位算力中心';
|
||||
case 'supercomputer': return data.name || '超算';
|
||||
@@ -1709,12 +1715,12 @@ export function showInfoCard(type, data, options = {}) {
|
||||
if (icon) icon.textContent = config.icon;
|
||||
if (title) {
|
||||
title.textContent = type === 'news'
|
||||
? (data?.title || '新闻事件')
|
||||
? getNewsDisplayTitle(data)
|
||||
: config.title;
|
||||
}
|
||||
if (typeLabel) {
|
||||
typeLabel.textContent = type === 'news'
|
||||
? 'news signal'
|
||||
? '新闻信号'
|
||||
: type.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
@@ -1750,7 +1756,7 @@ export function showInfoCard(type, data, options = {}) {
|
||||
card.className = 'info-card ' + config.className;
|
||||
icon.textContent = config.icon;
|
||||
title.textContent = type === 'news'
|
||||
? (data?.title || '新闻事件')
|
||||
? getNewsDisplayTitle(data)
|
||||
: config.title;
|
||||
|
||||
if (type === 'news') {
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
setSurfaceTintEnabled,
|
||||
toggleCountryBoundaries,
|
||||
updateCountryBoundaryHover,
|
||||
updateCountryBoundaryTiles,
|
||||
} from "./country-boundaries.js";
|
||||
import {
|
||||
loadGeoJSONFromPath,
|
||||
@@ -254,7 +255,7 @@ import {
|
||||
refreshLegend,
|
||||
setLegendItems,
|
||||
} from "./legend.js";
|
||||
import { mountBrand } from "./brand.js";
|
||||
import { fetchEarthBrandConfig, mountBrand } from "./brand.js";
|
||||
import { initTVPanel } from "./tv.js";
|
||||
import { initNewsPanel, updateNewsViewFocus } from "./news.js";
|
||||
import { initSearchPanel } from "./search.js";
|
||||
@@ -629,7 +630,6 @@ function clearTransientHoverState() {
|
||||
resetTransientBGPStates();
|
||||
resetTransientComputeCenterStates();
|
||||
resetTransientVesselStates();
|
||||
clearCountryBoundaryHover();
|
||||
hoveredBGP = null;
|
||||
hoveredComputeCenter = null;
|
||||
hoveredVessel = null;
|
||||
@@ -3569,6 +3569,13 @@ export function init() {
|
||||
updateHudScale();
|
||||
const brandRoot = document.getElementById("brand-root");
|
||||
mountBrand(brandRoot, HUD_CONFIG.brandLanguage);
|
||||
fetchEarthBrandConfig()
|
||||
.then((brandConfig) => {
|
||||
mountBrand(brandRoot, brandConfig);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Earth brand config unavailable, using defaults.", error);
|
||||
});
|
||||
initTVPanel();
|
||||
initNewsPanel();
|
||||
initSearchPanel({
|
||||
@@ -4118,6 +4125,26 @@ export async function setCountryBoundariesEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
export async function reloadCountryBoundaries({ suppressStatus = false } = {}) {
|
||||
const wasVisible = getShowCountryBoundaries();
|
||||
clearCountryBoundaryHover();
|
||||
clearCountryBoundaryData();
|
||||
if (wasVisible) {
|
||||
return await setCountryBoundariesEnabled(true, { suppressStatus });
|
||||
}
|
||||
|
||||
const countryCount = await ensureCountryBoundariesReady();
|
||||
const textureOn = getEarthTextureVisible();
|
||||
toggleCountryBoundaries(false, {
|
||||
showTint: !textureOn,
|
||||
showLandFill: true,
|
||||
suppressLandFill: false,
|
||||
});
|
||||
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
|
||||
refreshLegend();
|
||||
return countryCount;
|
||||
}
|
||||
|
||||
let _dayNightBeforeTextureOff = null;
|
||||
let _terrainBeforeTextureOff = null;
|
||||
|
||||
@@ -4525,7 +4552,9 @@ function onMouseMove(event) {
|
||||
inertialVelocity.y = rotationDeltaY;
|
||||
inertialVelocity.x = rotationDeltaX;
|
||||
previousMousePosition = { x: event.clientX, y: event.clientY };
|
||||
clearCountryBoundaryHover();
|
||||
if (!document.body.classList.contains("layout-mode-mobile")) {
|
||||
clearCountryBoundaryHover();
|
||||
}
|
||||
hideTooltip();
|
||||
return;
|
||||
}
|
||||
@@ -4568,6 +4597,34 @@ function onMouseMove(event) {
|
||||
computeCenterIntersects.length > 0 ? computeCenterIntersects[0].object : null;
|
||||
const hoveredVesselMarker =
|
||||
vesselPick.checked && vesselIntersects.length > 0 ? vesselIntersects[0].object : null;
|
||||
const earthPoint = screenToEarthCoords(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
camera,
|
||||
getEarthSurfacePickTarget() || earth,
|
||||
document.body,
|
||||
interactionRaycaster,
|
||||
interactionMouse,
|
||||
);
|
||||
const surfaceHover = earthPoint
|
||||
? { coords: vector3ToLatLon(earthPoint), hoveredCountry: null }
|
||||
: null;
|
||||
|
||||
if (surfaceHover) {
|
||||
updateCoordinatesDisplay(
|
||||
surfaceHover.coords.lat,
|
||||
surfaceHover.coords.lon,
|
||||
surfaceHover.coords.alt,
|
||||
);
|
||||
surfaceHover.hoveredCountry = getShowCountryBoundaries()
|
||||
? updateCountryBoundaryHover(surfaceHover.coords)
|
||||
: null;
|
||||
if (!getShowCountryBoundaries()) {
|
||||
clearCountryBoundaryHover();
|
||||
}
|
||||
} else {
|
||||
clearCountryBoundaryHover();
|
||||
}
|
||||
|
||||
if (
|
||||
hoveredComputeCenter &&
|
||||
@@ -4685,18 +4742,8 @@ function onMouseMove(event) {
|
||||
}
|
||||
|
||||
if (!objectTooltipShown) {
|
||||
const earthPoint = screenToEarthCoords(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
camera,
|
||||
getEarthSurfacePickTarget() || earth,
|
||||
document.body,
|
||||
interactionRaycaster,
|
||||
interactionMouse,
|
||||
);
|
||||
if (earthPoint) {
|
||||
const coords = vector3ToLatLon(earthPoint);
|
||||
updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
|
||||
if (surfaceHover) {
|
||||
const { coords, hoveredCountry } = surfaceHover;
|
||||
const hoverInfoMode = getSurfaceHoverInfoMode();
|
||||
const shouldShowCountry =
|
||||
hoverInfoMode === SURFACE_HOVER_INFO_MODES.COUNTRY ||
|
||||
@@ -4704,18 +4751,11 @@ function onMouseMove(event) {
|
||||
const shouldShowPosition =
|
||||
hoverInfoMode === SURFACE_HOVER_INFO_MODES.POSITION ||
|
||||
hoverInfoMode === SURFACE_HOVER_INFO_MODES.FULL;
|
||||
const hoveredCountry = shouldShowCountry && getShowCountryBoundaries()
|
||||
? updateCountryBoundaryHover(coords)
|
||||
: null;
|
||||
const positionHtml = shouldShowPosition
|
||||
? getSurfacePositionBriefHtml(coords)
|
||||
: "";
|
||||
|
||||
if (!shouldShowCountry) {
|
||||
clearCountryBoundaryHover();
|
||||
}
|
||||
|
||||
if (hoveredCountry && shouldShowPosition) {
|
||||
if (hoveredCountry && shouldShowCountry && shouldShowPosition) {
|
||||
showTooltip(
|
||||
event.clientX + TOOLTIP_CURSOR_OFFSET,
|
||||
event.clientY + TOOLTIP_CURSOR_OFFSET,
|
||||
@@ -4724,7 +4764,7 @@ function onMouseMove(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hoveredCountry) {
|
||||
if (hoveredCountry && shouldShowCountry) {
|
||||
showTooltip(
|
||||
event.clientX + TOOLTIP_CURSOR_OFFSET,
|
||||
event.clientY + TOOLTIP_CURSOR_OFFSET,
|
||||
@@ -4733,7 +4773,6 @@ function onMouseMove(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearCountryBoundaryHover();
|
||||
if (shouldShowPosition) {
|
||||
showTooltip(
|
||||
event.clientX + TOOLTIP_COORDS_OFFSET,
|
||||
@@ -4747,8 +4786,6 @@ function onMouseMove(event) {
|
||||
clearCountryBoundaryHover();
|
||||
hideTooltip();
|
||||
}
|
||||
} else {
|
||||
clearCountryBoundaryHover();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5210,6 +5247,12 @@ function animate() {
|
||||
renderer,
|
||||
now: performance.now(),
|
||||
});
|
||||
updateCountryBoundaryTiles({
|
||||
camera,
|
||||
earth: getEarthSurfacePickTarget() || earth,
|
||||
renderer,
|
||||
viewZoom: getZoomLevel(),
|
||||
});
|
||||
updateNewsViewFocus(getCurrentViewCenterCoords());
|
||||
const satPositions = getSatellitePositions();
|
||||
if (
|
||||
|
||||
@@ -13,6 +13,13 @@ import {
|
||||
selectNewsItem,
|
||||
clearSelectedNewsItem,
|
||||
} from "./news.js";
|
||||
import {
|
||||
getNewsDisplaySummary,
|
||||
getNewsDisplayTitle,
|
||||
getNewsFeedLabel,
|
||||
getNewsLocationSourceLabel,
|
||||
getNewsRegionLabel,
|
||||
} from "./news-locale.js";
|
||||
|
||||
const CRUISE_PRESENTATION_HIDE_MS = 220;
|
||||
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
|
||||
@@ -22,14 +29,6 @@ const MOBILE_CARD_TOP_RATIO = 0.16;
|
||||
const MOBILE_CARD_WIDTH_PX = 220;
|
||||
const scratchNewsWorldPosition = new THREE.Vector3();
|
||||
|
||||
const REGION_LABELS = {
|
||||
americas: "美洲",
|
||||
europe: "欧洲",
|
||||
"middle-east-africa": "中东与非洲",
|
||||
"asia-pacific": "亚太",
|
||||
global: "全球",
|
||||
};
|
||||
|
||||
function getItemTimestamp(item) {
|
||||
const parsed = item?.published_at ? new Date(item.published_at).getTime() : 0;
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
@@ -94,18 +93,24 @@ function mapNewsItemToCruiseEvent(item) {
|
||||
id: `news:${item.id}`,
|
||||
sourceId: item.id,
|
||||
type: "news",
|
||||
title: item.title || "新闻事件",
|
||||
summary: item.summary || "",
|
||||
title: getNewsDisplayTitle(item),
|
||||
summary: getNewsDisplaySummary(item),
|
||||
source: item.source || "",
|
||||
feedName: item.feed_name || "",
|
||||
feedName: getNewsFeedLabel(item.feed_name),
|
||||
region: item.region || "global",
|
||||
regionLabel: REGION_LABELS[item.region] || item.region || "全球",
|
||||
regionLabel: item.display_region || getNewsRegionLabel(item.region),
|
||||
url: item.url || "",
|
||||
publishedAt: item.published_at || null,
|
||||
publishedAtDisplay: formatPublishedAt(item.published_at),
|
||||
latitude,
|
||||
longitude,
|
||||
locationLabel: item.location_label || REGION_LABELS[item.region] || "全球",
|
||||
locationLabel: item.location_label || item.display_region || getNewsRegionLabel(item.region),
|
||||
sourceLocationLabel: item.location_label || item.display_region || getNewsRegionLabel(item.region),
|
||||
targetLocationConfidence: item.location_meta?.target?.confidence ?? null,
|
||||
targetLocationSource: item.location_source || "",
|
||||
targetLocationSourceLabel: getNewsLocationSourceLabel(item.location_source),
|
||||
verified: item.verified === true,
|
||||
locationMeta: item.location_meta || null,
|
||||
sortTimestamp: getItemTimestamp(item),
|
||||
};
|
||||
}
|
||||
|
||||
107
frontend/public/earth/js/news-locale.js
Normal file
107
frontend/public/earth/js/news-locale.js
Normal file
@@ -0,0 +1,107 @@
|
||||
const DEFAULT_LOCALE = "zh-CN";
|
||||
|
||||
const REGION_LABELS = {
|
||||
americas: "美洲",
|
||||
europe: "欧洲",
|
||||
"middle-east-africa": "中东与非洲",
|
||||
"asia-pacific": "亚太",
|
||||
global: "全球",
|
||||
};
|
||||
|
||||
const FEED_LABELS = {
|
||||
"Global Monitor / World": "全球监测",
|
||||
"Global Monitor / Americas": "美洲监测",
|
||||
"Global Monitor / Europe": "欧洲监测",
|
||||
"Global Monitor / MEA": "中东与非洲监测",
|
||||
"Global Monitor / APAC": "亚太监测",
|
||||
};
|
||||
|
||||
const LOCATION_SOURCE_LABELS = {
|
||||
region_anchor: "区域锚点",
|
||||
ai_inferred_target: "AI 推断位置",
|
||||
headline_location_hint: "标题位置线索",
|
||||
headline_country_hint: "标题国家线索",
|
||||
};
|
||||
|
||||
const ENRICHMENT_STATUS_LABELS = {
|
||||
pending: "待增强",
|
||||
queued: "增强排队中",
|
||||
attempted: "增强中",
|
||||
success: "已汉化",
|
||||
content_only: "已汉化",
|
||||
location_only: "位置已增强",
|
||||
unavailable: "AI 未配置",
|
||||
provider_error: "增强失败",
|
||||
parse_error: "增强解析失败",
|
||||
no_result: "暂无增强结果",
|
||||
};
|
||||
|
||||
const TITLE_PLACEHOLDERS = {
|
||||
queued: "新闻汉化排队中",
|
||||
attempted: "新闻汉化中",
|
||||
provider_error: "新闻汉化失败,正在重试",
|
||||
parse_error: "新闻解析失败,正在重试",
|
||||
unavailable: "等待 AI 配置",
|
||||
no_result: "新闻汉化待重试",
|
||||
location_only: "新闻汉化待重试",
|
||||
};
|
||||
|
||||
const SUMMARY_PLACEHOLDERS = {
|
||||
queued: "中文概要正在生成,请稍后刷新。",
|
||||
attempted: "中文概要正在生成,请稍后刷新。",
|
||||
provider_error: "中文概要生成失败,系统会重新提交增强任务。",
|
||||
parse_error: "中文概要解析失败,系统会重新提交增强任务。",
|
||||
unavailable: "AI 服务配置完成后将生成中文概要。",
|
||||
no_result: "中文概要暂未生成,系统会继续重试。",
|
||||
location_only: "已完成位置增强,中文概要将继续重试。",
|
||||
};
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function getLocalization(item, locale = DEFAULT_LOCALE) {
|
||||
const localizations = item?.localizations;
|
||||
const localized = localizations && typeof localizations === "object"
|
||||
? localizations[locale]
|
||||
: null;
|
||||
return localized && typeof localized === "object" ? localized : {};
|
||||
}
|
||||
|
||||
export function getNewsDisplayTitle(item, locale = DEFAULT_LOCALE) {
|
||||
const localized = getLocalization(item, locale).title;
|
||||
if (localized || item?.display_title) {
|
||||
return normalizeText(item?.display_title || localized);
|
||||
}
|
||||
return normalizeText(
|
||||
TITLE_PLACEHOLDERS[item?.enrichment_status]
|
||||
|| "新闻汉化中",
|
||||
);
|
||||
}
|
||||
|
||||
export function getNewsDisplaySummary(item, locale = DEFAULT_LOCALE) {
|
||||
const localized = getLocalization(item, locale).summary;
|
||||
if (localized || item?.display_summary) {
|
||||
return normalizeText(item?.display_summary || localized);
|
||||
}
|
||||
return normalizeText(
|
||||
SUMMARY_PLACEHOLDERS[item?.enrichment_status]
|
||||
|| "中文概要生成中,请稍后刷新。",
|
||||
);
|
||||
}
|
||||
|
||||
export function getNewsRegionLabel(region, fallback = "") {
|
||||
return REGION_LABELS[region] || fallback || region || REGION_LABELS.global;
|
||||
}
|
||||
|
||||
export function getNewsFeedLabel(feedName) {
|
||||
return FEED_LABELS[feedName] || feedName || "聚合源";
|
||||
}
|
||||
|
||||
export function getNewsLocationSourceLabel(source) {
|
||||
return LOCATION_SOURCE_LABELS[source] || source || "位置来源";
|
||||
}
|
||||
|
||||
export function getNewsEnrichmentStatusLabel(status) {
|
||||
return ENRICHMENT_STATUS_LABELS[status] || status || "增强状态";
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import {
|
||||
getNewsDisplaySummary,
|
||||
getNewsDisplayTitle,
|
||||
getNewsEnrichmentStatusLabel,
|
||||
getNewsFeedLabel,
|
||||
getNewsRegionLabel,
|
||||
} from "./news-locale.js";
|
||||
|
||||
// Desktop news has two surfaces:
|
||||
// - a persistent top ticker
|
||||
@@ -14,6 +21,7 @@ const NEWS_HUD_MORPH_MS = 300;
|
||||
const NEWS_HUD_MIN_WIDTH_PX = 420;
|
||||
const NEWS_HUD_MIN_HEIGHT_PX = 360;
|
||||
const NEWS_HUD_RESIZE_MARGIN_PX = 12;
|
||||
const NEWS_REALTIME_RECONNECT_MS = 5000;
|
||||
|
||||
let initialized = false;
|
||||
let refreshPromise = null;
|
||||
@@ -23,6 +31,8 @@ let lastFetchAt = 0;
|
||||
let lastRegionSwitchAt = 0;
|
||||
let selectedCruiseStoryId = null;
|
||||
let morphTimer = null;
|
||||
let newsRealtimeSocket = null;
|
||||
let newsRealtimeReconnectTimer = null;
|
||||
|
||||
function getElements() {
|
||||
const isMobile = document.body.classList.contains("layout-mode-mobile");
|
||||
@@ -282,6 +292,14 @@ function escapeTickerText(value) {
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function escapeNewsHtml(value) {
|
||||
return escapeTickerText(value);
|
||||
}
|
||||
|
||||
function hasLocalizedNewsContent(item) {
|
||||
return Boolean(String(item?.display_title || "").trim() && String(item?.display_summary || "").trim());
|
||||
}
|
||||
|
||||
function renderTicker(nextPayload) {
|
||||
const { ticker, tickerRegion, tickerTrack } = getElements();
|
||||
if (!(ticker instanceof HTMLElement) || !(tickerTrack instanceof HTMLElement)) return;
|
||||
@@ -289,7 +307,7 @@ function renderTicker(nextPayload) {
|
||||
const focus = nextPayload?.focus || {};
|
||||
|
||||
if (tickerRegion instanceof HTMLElement) {
|
||||
tickerRegion.textContent = (focus.region || "global").toUpperCase();
|
||||
tickerRegion.textContent = focus.display_region || getNewsRegionLabel(focus.region);
|
||||
tickerRegion.style.color = focus.accent || "";
|
||||
}
|
||||
|
||||
@@ -299,13 +317,18 @@ function renderTicker(nextPayload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleItems = items.slice(0, 6);
|
||||
const visibleItems = items.filter(hasLocalizedNewsContent).slice(0, 6);
|
||||
if (visibleItems.length === 0) {
|
||||
tickerTrack.textContent = "正在等待中文新闻...";
|
||||
tickerTrack.style.removeProperty("--news-ticker-duration");
|
||||
return;
|
||||
}
|
||||
const tickerItems = [...visibleItems, ...visibleItems];
|
||||
tickerTrack.innerHTML = tickerItems
|
||||
.map((item) => `
|
||||
<span class="earth-news-ticker__item" data-news-id="${escapeTickerText(item.id || "")}">
|
||||
<span class="earth-news-ticker__source">${escapeTickerText(item.source || item.feed_name || "NEWS")}</span>
|
||||
<span>${escapeTickerText(item.title || "未命名新闻")}</span>
|
||||
<span>${escapeTickerText(getNewsDisplaySummary(item))}</span>
|
||||
</span>
|
||||
`)
|
||||
.join("");
|
||||
@@ -357,7 +380,7 @@ function renderPayload(nextPayload) {
|
||||
}
|
||||
|
||||
if (regionChip) {
|
||||
regionChip.textContent = focus.region || "global";
|
||||
regionChip.textContent = focus.display_region || getNewsRegionLabel(focus.region);
|
||||
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
|
||||
}
|
||||
|
||||
@@ -409,20 +432,27 @@ function renderPayload(nextPayload) {
|
||||
const cardClass = item.is_focus_match
|
||||
? "news-story-card news-story-card--focus"
|
||||
: "news-story-card";
|
||||
const summary = item.summary
|
||||
? `<div class="news-story-summary">${item.summary}</div>`
|
||||
const title = getNewsDisplayTitle(item);
|
||||
const summaryText = getNewsDisplaySummary(item);
|
||||
const leadText = summaryText || title;
|
||||
const regionLabel = item.display_region || getNewsRegionLabel(item.region);
|
||||
const feedLabel = getNewsFeedLabel(item.feed_name);
|
||||
const statusLabel = getNewsEnrichmentStatusLabel(item.enrichment_status);
|
||||
const summary = title && title !== leadText
|
||||
? `<div class="news-story-summary">${escapeNewsHtml(title)}</div>`
|
||||
: "";
|
||||
return `
|
||||
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener">
|
||||
<div class="news-story-meta">
|
||||
<span class="news-story-source">${item.source}</span>
|
||||
<span class="news-story-source">${escapeNewsHtml(item.source || "NEWS")}</span>
|
||||
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
|
||||
</div>
|
||||
<div class="news-story-title">${item.title}</div>
|
||||
<div class="news-story-title">${escapeNewsHtml(leadText)}</div>
|
||||
${summary}
|
||||
<div class="news-story-tags">
|
||||
<span class="news-story-tag">${item.region}</span>
|
||||
<span class="news-story-tag">${item.feed_name}</span>
|
||||
<span class="news-story-tag">${escapeNewsHtml(regionLabel)}</span>
|
||||
<span class="news-story-tag">${escapeNewsHtml(feedLabel)}</span>
|
||||
<span class="news-story-tag">${escapeNewsHtml(statusLabel)}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
@@ -438,6 +468,84 @@ function renderPayload(nextPayload) {
|
||||
}));
|
||||
}
|
||||
|
||||
function getNewsRealtimeUrl() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
function clearNewsRealtimeReconnectTimer() {
|
||||
if (!newsRealtimeReconnectTimer) return;
|
||||
window.clearTimeout(newsRealtimeReconnectTimer);
|
||||
newsRealtimeReconnectTimer = null;
|
||||
}
|
||||
|
||||
function scheduleNewsRealtimeReconnect() {
|
||||
if (newsRealtimeReconnectTimer) return;
|
||||
newsRealtimeReconnectTimer = window.setTimeout(() => {
|
||||
newsRealtimeReconnectTimer = null;
|
||||
connectNewsRealtime();
|
||||
}, NEWS_REALTIME_RECONNECT_MS);
|
||||
}
|
||||
|
||||
function applyNewsRealtimePatch(updatePayload) {
|
||||
const itemId = updatePayload?.item_id;
|
||||
const patch = updatePayload?.patch;
|
||||
if (!payload || !itemId || !patch || typeof patch !== "object") return;
|
||||
const items = Array.isArray(payload.items) ? payload.items : [];
|
||||
let changed = false;
|
||||
const nextItems = items.map((item) => {
|
||||
if (item?.id !== itemId) return item;
|
||||
changed = true;
|
||||
return {
|
||||
...item,
|
||||
...patch,
|
||||
};
|
||||
});
|
||||
if (!changed) return;
|
||||
renderPayload({
|
||||
...payload,
|
||||
items: nextItems,
|
||||
});
|
||||
}
|
||||
|
||||
function connectNewsRealtime() {
|
||||
if (newsRealtimeSocket || typeof WebSocket === "undefined") return;
|
||||
const socket = new WebSocket(getNewsRealtimeUrl());
|
||||
newsRealtimeSocket = socket;
|
||||
socket.onopen = () => {
|
||||
clearNewsRealtimeReconnectTimer();
|
||||
socket.send(JSON.stringify({
|
||||
type: "subscribe",
|
||||
data: {
|
||||
channel: "earth_news",
|
||||
},
|
||||
}));
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.type === "heartbeat" && message.data?.action === "ping") {
|
||||
socket.send(JSON.stringify({ type: "heartbeat" }));
|
||||
return;
|
||||
}
|
||||
if (message.type !== "data_frame" || message.channel !== "earth_news") return;
|
||||
applyNewsRealtimePatch(message.payload);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (newsRealtimeSocket === socket) {
|
||||
newsRealtimeSocket = null;
|
||||
}
|
||||
scheduleNewsRealtimeReconnect();
|
||||
};
|
||||
socket.onerror = () => {
|
||||
socket.close();
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchNews(lat, lon) {
|
||||
const url = new URL(EARTH_NEWS_API, window.location.origin);
|
||||
if (typeof lat === "number") url.searchParams.set("lat", lat.toFixed(4));
|
||||
@@ -614,6 +722,7 @@ export function initNewsPanel() {
|
||||
});
|
||||
hudCloseBtn?.addEventListener("click", closeNewsHud);
|
||||
setupNewsHudResize();
|
||||
connectNewsRealtime();
|
||||
|
||||
["news-refresh", "mobile-news-refresh"].forEach((id) => {
|
||||
const refreshBtn = document.getElementById(id);
|
||||
|
||||
@@ -714,6 +714,7 @@ function createSatelliteTwinkleState(index) {
|
||||
function createSatellitePositionState(index = 0) {
|
||||
return {
|
||||
current: new THREE.Vector3(),
|
||||
currentTime: null,
|
||||
trail: [],
|
||||
trailIndex: 0,
|
||||
trailCount: 0,
|
||||
@@ -1309,6 +1310,7 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
|
||||
}
|
||||
|
||||
satellitePositions[i].current.copy(pos);
|
||||
satellitePositions[i].currentTime = adjustedTime;
|
||||
|
||||
if (shouldUpdateTrails) {
|
||||
const satPos = satellitePositions[i];
|
||||
@@ -2647,11 +2649,11 @@ function calculatePredictedOrbit(
|
||||
) {
|
||||
const points = [];
|
||||
const samples = Math.ceil(periodSeconds / sampleInterval);
|
||||
const now = new Date();
|
||||
const fixedSiderealTime = gstime(now);
|
||||
const startTime = getSelectedSatellitePositionState(satellite)?.currentTime || new Date();
|
||||
const fixedSiderealTime = gstime(startTime);
|
||||
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const time = new Date(now.getTime() + i * sampleInterval * 1000);
|
||||
const time = new Date(startTime.getTime() + i * sampleInterval * 1000);
|
||||
const pos = computeSatelliteInertialOrbitPosition(
|
||||
satellite,
|
||||
time,
|
||||
@@ -2710,6 +2712,21 @@ function calculateFallbackPredictedOrbit(satellite, samples) {
|
||||
return points;
|
||||
}
|
||||
|
||||
function getSelectedSatellitePositionState(satellite) {
|
||||
if (selectedSatellite === null || !satellitePositions?.[selectedSatellite]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedData = satelliteData?.[selectedSatellite];
|
||||
const selectedNoradId = selectedData?.properties?.norad_cat_id;
|
||||
const targetNoradId = satellite?.properties?.norad_cat_id;
|
||||
if (selectedData !== satellite && selectedNoradId !== targetNoradId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return satellitePositions[selectedSatellite];
|
||||
}
|
||||
|
||||
export function showPredictedOrbit(satellite) {
|
||||
hidePredictedOrbit();
|
||||
if (!earthObjRef) return;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user