Compare commits

..

11 Commits

Author SHA1 Message Date
e65267fe21 release: bump version to 0.65.2
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
2026-05-22 21:37:38 +08:00
ae982e51cd release: bump version to 0.65.1
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
2026-05-22 20:42:03 +08:00
rayd1o
65e6a96c0d release: bump version to 0.65.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
ci / backend (pull_request) Has been cancelled
ci / frontend (pull_request) Has been cancelled
ci / delivery (pull_request) Has been cancelled
2026-05-21 05:41:49 +08:00
linkong
37e92e7572 release: bump version to 0.64.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-05-21 03:46:02 +08:00
linkong
a37d4b6289 fix: update default linkong password
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
2026-05-21 02:20:45 +08:00
linkong
69789d7505 release: bump version to 0.63.1
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-05-21 02:14:11 +08:00
linkong
4f124121e7 fix: include admin next utils module
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
2026-05-21 02:07:39 +08:00
linkong
085bdf9a80 release: bump version to 0.63.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-05-21 01:46:43 +08:00
linkong
fbca381512 release: bump version to 0.62.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-05-21 01:37:32 +08:00
rayd1o
5c65ee24d6 release: bump version to 0.61.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
2026-05-18 02:37:19 +08:00
rayd1o
81970a1d05 release: bump version to 0.60.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
2026-05-17 02:50:42 +08:00
184 changed files with 27593 additions and 5958 deletions

View File

@@ -73,6 +73,7 @@ Style:
- Use fenced code blocks with language tags.
- Prefer tables for comparisons or parameter lists.
- Keep snippets concise and relevant.
- For UI labels, chart labels, feature names, datasource names, and other terms that may become mixed Chinese/English copy, check `docs/technical/{zh,en}/naming-glossary.md` and use the documented display name. If a confusing term is missing, update the glossary in both languages as part of the docs change.
### Step 4 — Verify

View File

@@ -52,6 +52,7 @@ rg -n "class |def |function |export |router|@router|interface |type " <path>
- Keep code snippets short and directly relevant.
- List related files only when they help future maintainers navigate.
- Use the repositorys existing language, heading style, and naming conventions.
- For UI labels, chart labels, feature names, datasource names, and other terms that may become mixed Chinese/English copy, check `docs/technical/{zh,en}/naming-glossary.md` and use the documented display name. If a confusing term is missing, update the glossary in both languages as part of the docs change.
4. Verify:

213
README.md
View File

@@ -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/ # 脚本
@@ -191,10 +169,11 @@
## 快速启动
```bash
# 新机器首次初始化
./scripts/bootstrap-dev.sh
# 会自动安装/检查 uv、bun同步 Python/前端依赖
# 新机器或空项目首次初始化
./planet.sh init
# 会自动安装/检查 uv、bun同步 Python/前端依赖
# 会在缺少时生成 backend/.env、aiprovider/.env、frontend/.env.local
# 会启动 PostgreSQL/Redis并创建表、默认数据源和本地默认用户
# 启动前后端服务
./planet.sh start
@@ -210,6 +189,9 @@
# 查看服务状态
./planet.sh health
# 删除容器、卷、镜像和本地编译状态,执行前需要输入 Y 确认
./planet.sh destroy
```
前端命令约定:
@@ -364,14 +346,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`
主后端建议配置:
@@ -384,39 +375,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
```
如果你要本地直接起模型适配层,项目里已经补了模板:
@@ -424,12 +409,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`
@@ -439,11 +418,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)
## 前端页面布局规范

11
TODO.md
View File

@@ -4,6 +4,9 @@ This file is the active backlog only. Completed history belongs in `docs/CHANGEL
## Earth
- [ ] 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.
@@ -32,6 +35,14 @@ This file is the active backlog only. Completed history belongs in `docs/CHANGEL
## AI Provider And Agents
- [ ] Unified integration config schema: implement the shared low-code schema engine for datasource, AI Provider, Web Search, and OCR configuration described in [Integration Config Schema System Plan](/home/ray/dev/linkong/planet/docs/plans/integration-config-schema-system-plan.md).
- [ ] AI provider routing: finish the OpenClaw-style provider/model routing refactor described in [AI Provider OpenClaw-Style Routing Plan](/home/ray/dev/linkong/planet/docs/plans/ai-provider-openclaw-style-routing-plan.md), so model-specific transport rules live in provider metadata rather than runtime hardcoding.
- [ ] AI provider catalog: replace the temporary `model_provider_apis` bridge with structured `models_metadata`, discovery descriptors, and incremental model sync with stale marking.
- [ ] AI provider connectivity: keep the plug action as lightweight network/auth/model-directory validation only, and keep real generation tests inside Playground or explicit “trial run” actions.
- [ ] 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.

View File

@@ -1 +1 @@
0.59.0
0.65.2

View File

@@ -2,10 +2,14 @@
ARG PYTHON_IMAGE=python:3.14-slim
ARG UV_IMAGE=ghcr.io/astral-sh/uv:latest
ARG AI_PROVIDER_BUILD_FINGERPRINT=unknown
FROM ${UV_IMAGE} AS uv
FROM ${PYTHON_IMAGE}
ARG AI_PROVIDER_BUILD_FINGERPRINT
LABEL planet.aiprovider.build-fingerprint="${AI_PROVIDER_BUILD_FINGERPRINT}"
COPY --from=uv /uv /uvx /bin/
WORKDIR /app
@@ -15,12 +19,15 @@ ENV PYTHONUNBUFFERED=1
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
RUN mkdir -p /root/.config/uv
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock /app/
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=secret,id=planet_uv_config,target=/root/.config/uv/uv.toml,required=false \
uv sync --frozen --no-dev
COPY aiprovider /app/aiprovider

View File

@@ -45,6 +45,7 @@ def get_provider_service(
x_ai_model: str | None = Header(default=None),
x_ai_max_tokens: str | None = Header(default=None),
x_ai_anthropic_version: str | None = Header(default=None),
x_ai_model_provider_apis: str | None = Header(default=None),
) -> ProviderService:
overrides = {
"provider": x_ai_provider,
@@ -53,6 +54,7 @@ def get_provider_service(
"api_key": x_ai_api_key,
"model": x_ai_model,
"anthropic_version": x_ai_anthropic_version,
"model_provider_apis": x_ai_model_provider_apis,
}
if x_ai_max_tokens:
overrides["max_tokens"] = x_ai_max_tokens

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import json
from typing import Any
import httpx
@@ -14,7 +15,6 @@ from aiprovider.schemas import (
SituationalAnalysisResponse,
)
def _normalize_provider(value: str) -> str:
return (value or "disabled").strip().lower()
@@ -62,6 +62,9 @@ class ProviderService:
self.anthropic_version = str(
overrides.get("anthropic_version") or settings.AI_ANTHROPIC_VERSION
)
self.model_provider_apis = self._parse_model_provider_apis(
overrides.get("model_provider_apis")
)
def get_status(self) -> AIProviderStatusResponse:
enabled = self.provider != "disabled"
@@ -93,11 +96,13 @@ class ProviderService:
prompt = self._build_prompt(payload)
if self.provider_api == "openai-completions":
provider_api = self._resolve_model_provider_api(model)
if provider_api == "openai-completions":
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":
elif provider_api == "anthropic-messages":
data = await self._request_anthropic_messages(
model,
prompt,
@@ -106,7 +111,7 @@ class ProviderService:
)
content = self._extract_anthropic_content(data)
content_blocks = self._extract_anthropic_blocks(data)
elif self.provider_api == "ollama-generate":
elif provider_api == "ollama-generate":
data = await self._request_ollama(model, prompt, payload.system_prompt)
content = self._extract_ollama_content(data)
content_blocks = self._extract_ollama_blocks(data)
@@ -132,6 +137,26 @@ class ProviderService:
def _requires_api_key(self) -> bool:
return self.provider_api != "ollama-generate"
def _resolve_model_provider_api(self, model: str) -> str:
return self.model_provider_apis.get(model) or self.provider_api
def _parse_model_provider_apis(self, value: Any) -> dict[str, str]:
if isinstance(value, dict):
raw = value
elif isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
raw = parsed if isinstance(parsed, dict) else {}
else:
raw = {}
return {
str(model): _normalize_provider_api(str(provider_api))
for model, provider_api in raw.items()
if model and provider_api
}
def _build_prompt(self, payload: SituationalAnalysisRequest) -> str:
sections = [
f"任务标题:\n{payload.title}",
@@ -314,13 +339,19 @@ class ProviderService:
message = choices[0].get("message") or {}
content = message.get("content")
if isinstance(content, str):
return content
if content:
return content
reasoning_content = message.get("reasoning_content")
return reasoning_content if isinstance(reasoning_content, str) else ""
if isinstance(content, list):
return "".join(
item.get("text", "")
for item in content
if isinstance(item, dict)
)
reasoning_content = message.get("reasoning_content")
if isinstance(reasoning_content, str):
return reasoning_content
return ""
def _extract_openai_blocks(self, payload: dict[str, Any]) -> list[AIContentBlock]:
@@ -331,9 +362,14 @@ class ProviderService:
message = choices[0].get("message") or {}
content = message.get("content")
if isinstance(content, str):
return [AIContentBlock(type="text", text=content)]
blocks = [AIContentBlock(type="text", text=content)] if content else []
reasoning_content = message.get("reasoning_content")
if isinstance(reasoning_content, str) and reasoning_content:
blocks.append(AIContentBlock(type="thinking", thinking=reasoning_content))
return blocks
if not isinstance(content, list):
return []
reasoning_content = message.get("reasoning_content")
return [AIContentBlock(type="thinking", thinking=reasoning_content)] if isinstance(reasoning_content, str) and reasoning_content else []
blocks: list[AIContentBlock] = []
for item in content:
@@ -346,7 +382,11 @@ class ProviderService:
metadata={k: v for k, v in item.items() if k not in {"type", "text"}},
)
)
reasoning_content = message.get("reasoning_content")
if isinstance(reasoning_content, str) and reasoning_content:
blocks.append(AIContentBlock(type="thinking", thinking=reasoning_content))
return blocks
def _extract_anthropic_content(self, payload: dict[str, Any]) -> str:
content = payload.get("content")
if isinstance(content, str):

View File

@@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1.7
ARG PYTHON_IMAGE=python:3.14-slim
ARG UV_IMAGE=ghcr.io/astral-sh/uv:latest
@@ -14,12 +16,16 @@ ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
ENV PYTHONPATH=/app/backend
RUN mkdir -p /root/.config/uv
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock /app/
RUN uv sync --frozen --no-dev
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=secret,id=planet_uv_config,target=/root/.config/uv/uv.toml,required=false \
uv sync --frozen --no-dev
COPY backend /app/backend
COPY VERSION /app/VERSION

View File

@@ -3,9 +3,9 @@
"key": "earth.news.enrich",
"label": "Earth 新闻汉化与定位",
"group": "Earth 新闻",
"version": "2026-05-16.1",
"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 and summary based only on the supplied RSS headline, description, source, and date."
"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",
@@ -27,8 +27,8 @@
"key": "bgp.brief",
"label": "BGP 态势简报",
"group": "BGP",
"version": "2026-05-16.1",
"system_prompt": "",
"version": "2026-05-16.2",
"system_prompt": "你是 BGP 值班分析师。请直接输出面向值班人员的中文 Markdown 简报,只写最终研判内容;不要复述用户需求、提示词、写作计划、字段清单或“我将如何回答”。",
"prompt": "基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。"
},
{

View File

@@ -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,

View File

@@ -5,7 +5,7 @@ from datetime import datetime
import base64
import json
import re
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import delete, select, func
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
@@ -45,14 +45,70 @@ from app.services.custom_datasource_runtime import (
DATASOURCE_MAPPING_PROMPT_KEY = "datasource.mapping"
from app.services.datasource_connectivity import (
_resolve_aisstream_api_key,
_resolve_spacetrack_credentials_with_override,
get_builtin_connection_status,
save_connectivity_success,
strip_connectivity_validation,
test_builtin_connectivity,
)
from app.services.barentswatch import resolve_barentswatch_config
from app.services.persistent_logs import record_audit_log
router = APIRouter()
SECRET_REVEAL_ROLES = {"admin", "super_admin"}
def _user_role_value(user: User) -> str:
role = getattr(user, "role", "")
return str(getattr(role, "value", role) or "").lower()
def _user_display_name(user: User) -> str:
return str(getattr(user, "username", None) or getattr(user, "email", None) or getattr(user, "id", ""))
async def _record_datasource_secret_reveal(
*,
current_user: User,
request: Request,
target_id: str,
result: str,
details: dict[str, Any],
) -> None:
await record_audit_log(
action="datasource_config.secret.reveal",
actor_id=getattr(current_user, "id", None),
actor_name=_user_display_name(current_user),
target_type="datasource_config_secret",
target_id=target_id,
result=result,
ip=request.client.host if request.client else None,
details=details,
)
async def _ensure_datasource_secret_reveal_allowed(
current_user: User,
request: Request,
target_id: str,
details: dict[str, Any],
) -> None:
if _user_role_value(current_user) in SECRET_REVEAL_ROLES:
return
await _record_datasource_secret_reveal(
current_user=current_user,
request=request,
target_id=target_id,
result="denied",
details={**details, "role": _user_role_value(current_user)},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can reveal datasource credentials",
)
def _default_builtin_config(name: str) -> dict[str, Any]:
return {"timeout": 30, "retry": 3}
@@ -389,10 +445,14 @@ async def list_all_datasources(
db_config = db_configs.get(name)
default_config = _default_builtin_config(name)
default_url = yaml_url
db_auth_config = db_config.auth_config or {} if db_config else {}
result.append(
{
"name": name,
"requires_credentials": bool(metadata.get("requires_credentials", False)),
"credential_provider": metadata.get("credential_provider"),
"credential_status": metadata.get("credential_status", "none"),
"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
@@ -401,10 +461,20 @@ async def list_all_datasources(
"is_active": db_config.is_active if db_config else True,
"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_config": {
"client_id": db_auth_config.get("client_id") or "",
"username": db_auth_config.get("username") or "",
"key_name": db_auth_config.get("key_name") or db_auth_config.get("param_name") or "",
"param_name": db_auth_config.get("param_name") or db_auth_config.get("key_name") or "",
"location": db_auth_config.get("location") or db_auth_config.get("in") or "",
"in": db_auth_config.get("in") or db_auth_config.get("location") or "",
},
"auth_configured": {
"api_key": bool((db_config.auth_config or {}).get("api_key"))
if db_config
else False,
"api_key": bool(db_auth_config.get("api_key")),
"client_id": bool(db_auth_config.get("client_id")),
"client_secret": bool(db_auth_config.get("client_secret")),
"username": bool(db_auth_config.get("username")),
"password": bool(db_auth_config.get("password")),
},
"headers": db_config.headers if db_config else {},
"config": strip_connectivity_validation(db_config.config if db_config else default_config),
@@ -418,6 +488,96 @@ async def list_all_datasources(
return {"total": len(result), "data": result}
@router.get("/configs/secrets")
async def reveal_builtin_config_secrets(
request: Request,
name: str = Query(..., min_length=1),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Reveal configured built-in datasource credentials for admin editing."""
source = name.strip()
metadata = DEFAULT_DATASOURCES.get(source)
if not metadata or not metadata.get("requires_credentials"):
raise HTTPException(status_code=404, detail="Credentialed datasource config not found")
provider = str(metadata.get("credential_provider") or "")
target_id = f"datasource_config:{source}"
await _ensure_datasource_secret_reveal_allowed(
current_user,
request,
target_id,
{"source": source, "provider": provider},
)
result = await db.execute(select(DataSourceConfig).where(DataSourceConfig.name == source))
record = result.scalar_one_or_none()
auth_config = dict(record.auth_config or {}) if record else {}
payload: dict[str, Any] = {
"name": source,
"provider": provider,
}
details: dict[str, Any] = {"source": source, "provider": provider}
if provider == "barentswatch":
resolved = await resolve_barentswatch_config(db)
client_id = str(auth_config.get("client_id") or resolved.client_id or "")
client_secret = str(auth_config.get("client_secret") or resolved.client_secret or "")
source_label = "datasource_config" if auth_config.get("client_id") or auth_config.get("client_secret") else resolved.credential_source
payload.update(
{
"client_id": client_id,
"client_secret": client_secret,
"client_id_source": source_label if client_id else "missing",
"client_secret_source": source_label if client_secret else "missing",
}
)
details.update(
{
"client_id_configured": bool(client_id),
"client_secret_configured": bool(client_secret),
"credential_source": source_label,
}
)
elif provider == "aisstream":
api_key, api_key_source = await _resolve_aisstream_api_key(db)
payload.update({"api_key": api_key, "api_key_source": api_key_source})
details.update({"api_key_configured": bool(api_key), "api_key_source": api_key_source})
elif provider == "spacetrack":
if auth_config.get("username") or auth_config.get("password"):
username = str(auth_config.get("username") or "")
password = str(auth_config.get("password") or "")
credential_source = "datasource_config"
else:
username, password, credential_source = _resolve_spacetrack_credentials_with_override()
payload.update(
{
"username": username,
"password": password,
"username_source": credential_source if username else "missing",
"password_source": credential_source if password else "missing",
}
)
details.update(
{
"username_configured": bool(username),
"password_configured": bool(password),
"credential_source": credential_source,
}
)
else:
raise HTTPException(status_code=400, detail="Datasource credential provider is not supported")
await _record_datasource_secret_reveal(
current_user=current_user,
request=request,
target_id=target_id,
result="success",
details=details,
)
return payload
@router.get("/configs/{config_id}")
async def get_config(
config_id: int,

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.cache import cache
from app.core.time import to_iso8601_utc
from app.core.security import get_current_user
from app.core.data_sources import get_data_sources_config
@@ -26,6 +27,7 @@ from app.services.scheduler import (
run_collector_now,
sync_datasource_job,
)
from app.services.earth_layer_cache import invalidate_earth_layer_cache_for_source
router = APIRouter()
STALE_RUNNING_TASK_TIMEOUT_MINUTES = 90
@@ -235,6 +237,8 @@ async def _load_datasource_endpoint_overrides(
async def _load_datasource_list_context(
db: AsyncSession,
datasources: list[DataSource],
*,
include_endpoint: bool = True,
) -> 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]
@@ -260,10 +264,65 @@ async def _load_datasource_list_context(
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)
endpoint_overrides = await _load_datasource_endpoint_overrides(db, sources) if include_endpoint else {}
return running_tasks, latest_tasks, endpoint_overrides
def serialize_datasource_row(
datasource: DataSource,
*,
running_tasks: dict[int, CollectionTask],
latest_tasks: dict[int, CollectionTask],
record_counts: dict[str, int],
endpoint_overrides: dict[str, str],
config,
include_endpoint: bool,
) -> dict:
running_task = running_tasks.get(datasource.id)
latest_task = latest_tasks.get(datasource.id)
display_task = running_task or latest_task
endpoint = None
if include_endpoint:
endpoint = endpoint_overrides.get(datasource.source) or config.get_yaml_url(datasource.source)
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)
row = {
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
**datasource_metadata(datasource.source),
"product": datasource_product_key(datasource),
"module": datasource.module,
"priority": datasource.priority,
"frequency": format_frequency_label(datasource.frequency_minutes),
"frequency_minutes": datasource.frequency_minutes,
"is_active": datasource.is_active,
"collector_class": datasource.collector_class,
"last_run": to_iso8601_utc(last_run_at),
"last_run_at": to_iso8601_utc(last_run_at),
"last_status": last_status,
"is_running": running_task is not 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,
}
if include_endpoint:
row["endpoint"] = endpoint
return row
def _apply_datasource_query_filters(
query,
*,
@@ -295,13 +354,14 @@ def _filter_datasources_in_memory(
datasources: list[DataSource],
*,
running_tasks: dict[int, CollectionTask],
latest_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)
@@ -657,6 +717,7 @@ async def list_datasources(
collected: Optional[bool] = None,
credential_status: Optional[str] = None,
q: Optional[str] = None,
include_endpoint: bool = True,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@@ -675,7 +736,11 @@ async def list_datasources(
collector_list = []
config = get_data_sources_config()
running_tasks, latest_tasks, endpoint_overrides = await _load_datasource_list_context(db, datasources)
running_tasks, latest_tasks, endpoint_overrides = await _load_datasource_list_context(
db,
datasources,
include_endpoint=include_endpoint,
)
record_counts = await _load_collected_record_counts(db, [datasource.source for datasource in datasources])
datasources = _filter_datasources_in_memory(
datasources,
@@ -688,46 +753,16 @@ async def list_datasources(
credential_status=credential_status,
)
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 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(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
**datasource_metadata(datasource.source),
"product": datasource_product_key(datasource),
"module": datasource.module,
"priority": datasource.priority,
"frequency": format_frequency_label(datasource.frequency_minutes),
"frequency_minutes": datasource.frequency_minutes,
"is_active": datasource.is_active,
"collector_class": datasource.collector_class,
"endpoint": endpoint,
"last_run": to_iso8601_utc(last_run_at),
"last_run_at": to_iso8601_utc(last_run_at),
"last_status": last_status,
"is_running": running_task is not 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,
}
serialize_datasource_row(
datasource,
running_tasks=running_tasks,
latest_tasks=latest_tasks,
record_counts=record_counts,
endpoint_overrides=endpoint_overrides,
config=config,
include_endpoint=include_endpoint,
)
)
return {"total": len(collector_list), "data": collector_list}
@@ -784,6 +819,51 @@ async def trigger_datasource_batch(
return await _trigger_datasource_batch(db, datasources, force=payload.force)
@router.get("/snapshots")
async def list_datasource_snapshots(
source_id: Optional[str] = None,
current_only: Optional[bool] = None,
limit: int = Query(default=100, ge=1, le=500),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = (
select(DataSnapshot, DataSource.name, DataSource.module)
.outerjoin(DataSource, DataSource.id == DataSnapshot.datasource_id)
.order_by(DataSnapshot.created_at.desc().nullslast(), DataSnapshot.id.desc())
.limit(limit)
)
if source_id:
query = query.where(DataSnapshot.source == source_id)
if current_only is not None:
query = query.where(DataSnapshot.is_current.is_(current_only))
result = await db.execute(query)
rows = []
for snapshot, datasource_name, datasource_module in result.all():
rows.append(
{
"id": snapshot.id,
"datasource_id": snapshot.datasource_id,
"datasource_name": datasource_name,
"module": datasource_module,
"task_id": snapshot.task_id,
"source": snapshot.source,
"snapshot_key": snapshot.snapshot_key,
"reference_date": to_iso8601_utc(snapshot.reference_date),
"started_at": to_iso8601_utc(snapshot.started_at),
"completed_at": to_iso8601_utc(snapshot.completed_at),
"record_count": snapshot.record_count,
"status": snapshot.status,
"is_current": snapshot.is_current,
"parent_snapshot_id": snapshot.parent_snapshot_id,
"summary": snapshot.summary or {},
"created_at": to_iso8601_utc(snapshot.created_at),
}
)
return {"total": len(rows), "data": rows}
@router.get("/{source_id}")
async def get_datasource(
source_id: str,
@@ -812,6 +892,37 @@ async def get_datasource(
}
@router.get("/{source_id}/row")
async def get_datasource_row(
source_id: str,
include_endpoint: bool = True,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
datasource = await get_datasource_record(db, source_id)
if not datasource:
raise HTTPException(status_code=404, detail="Data source not found")
config = get_data_sources_config()
running_tasks, latest_tasks, endpoint_overrides = await _load_datasource_list_context(
db,
[datasource],
include_endpoint=include_endpoint,
)
record_counts = await _load_collected_record_counts(db, [datasource.source])
return {
"data": serialize_datasource_row(
datasource,
running_tasks=running_tasks,
latest_tasks=latest_tasks,
record_counts=record_counts,
endpoint_overrides=endpoint_overrides,
config=config,
include_endpoint=include_endpoint,
)
}
@router.post("/{source_id}/enable")
async def enable_datasource(
source_id: str,
@@ -959,6 +1070,29 @@ async def clear_datasource_data(
}
@router.delete("/{source_id}/cache")
async def clear_datasource_cache(
source_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
datasource = await get_datasource_record(db, source_id)
if not datasource:
raise HTTPException(status_code=404, detail="Data source not found")
earth_deleted_count = invalidate_earth_layer_cache_for_source(datasource.source)
dashboard_deleted_count = int(cache.delete("dashboard:stats")) + int(cache.delete("dashboard:summary"))
deleted_count = earth_deleted_count + dashboard_deleted_count
return {
"status": "success",
"message": f"Cleared {deleted_count} cache keys for data source '{datasource.name}'",
"deleted_count": deleted_count,
"earth_layer_deleted_count": earth_deleted_count,
"dashboard_deleted_count": dashboard_deleted_count,
}
@router.get("/{source_id}/task-status")
async def get_task_status(
source_id: str,

View File

@@ -2,17 +2,24 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy import delete, func, 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.collected_data import CollectedData
from app.models.datasource import DataSource
from app.models.datasource_config import DataSourceConfig
from app.models.system_setting import SystemSetting
from app.models.user import User
from app.services.tv_streams import get_tv_settings_payload
from app.services.earth_boundaries import (
EarthBoundaryBuildError,
get_boundary_build_status,
@@ -24,15 +31,145 @@ from app.services.earth_boundaries import (
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"
EARTH_ABOUT_CATEGORY = "earth_about"
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": "智能星球计划",
}
DEFAULT_EARTH_ABOUT = {
"logo_src": "/earth/assets/brand/lim-logo.png",
"kicker": "About",
"title": "智能星球计划",
"version": "v0.64.0",
"description": "面向临空场景下的智能媒体研究、全球态势感知与多源开放数据巡航,提供可视化观测、事件聚合与交互式探索能力。",
"meta": [
{"label": "出品方", "value": "浙江大学临空智能媒体研究院"},
{"label": "策划人", "value": "黄柳青"},
{"label": "产品兼开发者", "value": "钱坤、张鸽、齐鹏"},
],
}
class EarthBoundaryConfigPayload(BaseModel):
config: dict[str, Any] = Field(default_factory=dict)
@router.get("/boundaries/status")
async def get_earth_boundary_status():
return get_boundary_status()
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)
class EarthAboutMetaItem(BaseModel):
label: str = Field(default="", max_length=80)
value: str = Field(default="", max_length=240)
class EarthAboutPayload(BaseModel):
logo_src: str = Field(default=DEFAULT_EARTH_ABOUT["logo_src"], max_length=1000)
kicker: str = Field(default=DEFAULT_EARTH_ABOUT["kicker"], max_length=80)
title: str = Field(default=DEFAULT_EARTH_ABOUT["title"], max_length=160)
version: str = Field(default=DEFAULT_EARTH_ABOUT["version"], max_length=80)
description: str = Field(default=DEFAULT_EARTH_ABOUT["description"], max_length=800)
meta: list[EarthAboutMetaItem] = Field(default_factory=list)
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
def _normalize_earth_about_payload(payload: dict[str, Any] | None) -> dict[str, Any]:
merged: dict[str, Any] = {
key: value
for key, value in DEFAULT_EARTH_ABOUT.items()
if key != "meta"
}
raw_meta = DEFAULT_EARTH_ABOUT["meta"]
if payload:
for key in ("logo_src", "kicker", "title", "version", "description"):
value = payload.get(key)
if value is not None:
merged[key] = str(value).strip()
raw_meta = payload.get("meta") if isinstance(payload.get("meta"), list) else raw_meta
for key, default_value in DEFAULT_EARTH_ABOUT.items():
if key == "meta":
continue
if not merged.get(key):
merged[key] = default_value
normalized_meta: list[dict[str, str]] = []
for item in raw_meta:
if not isinstance(item, dict):
continue
label = str(item.get("label") or "").strip()
value = str(item.get("value") or "").strip()
if label or value:
normalized_meta.append({"label": label, "value": value})
if not normalized_meta:
normalized_meta = [dict(item) for item in DEFAULT_EARTH_ABOUT["meta"]]
merged["meta"] = normalized_meta
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,
}
async def _get_earth_about_record(db: AsyncSession) -> SystemSetting | None:
result = await db.execute(
select(SystemSetting).where(SystemSetting.category == EARTH_ABOUT_CATEGORY)
)
return result.scalar_one_or_none()
async def _get_earth_about_payload(db: AsyncSession) -> dict[str, Any]:
record = await _get_earth_about_record(db)
return {
"about": _normalize_earth_about_payload(record.payload if record else None),
"is_default": record is None,
}
async def _get_optional_current_user(
@@ -70,6 +207,168 @@ async def _get_optional_current_user(
return user
@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("/about")
async def get_earth_about(db: AsyncSession = Depends(get_db)):
return await _get_earth_about_payload(db)
@router.put("/about")
async def update_earth_about(
payload: EarthAboutPayload,
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
normalized = _normalize_earth_about_payload(payload.model_dump())
record = await _get_earth_about_record(db)
if record is None:
record = SystemSetting(category=EARTH_ABOUT_CATEGORY, payload=normalized)
db.add(record)
else:
record.payload = normalized
await db.commit()
await db.refresh(record)
return {"status": "updated", "about": _normalize_earth_about_payload(record.payload), "is_default": False}
@router.delete("/about")
async def reset_earth_about(
_current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await db.execute(delete(SystemSetting).where(SystemSetting.category == EARTH_ABOUT_CATEGORY))
await db.commit()
return {"status": "reset", "about": _normalize_earth_about_payload(None), "is_default": True}
@router.get("/oobe-status")
async def get_earth_oobe_status(
current_user: User | None = Depends(_get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
current_count_result = await db.execute(
select(func.count(CollectedData.id)).where(CollectedData.is_current.is_(True))
)
current_record_count = int(current_count_result.scalar() or 0)
datasource_count_result = await db.execute(select(func.count(DataSource.id)))
datasource_count = int(datasource_count_result.scalar() or 0)
active_datasource_count_result = await db.execute(
select(func.count(DataSource.id)).where(DataSource.is_active.is_(True))
)
active_datasource_count = int(active_datasource_count_result.scalar() or 0)
config_result = await db.execute(select(func.count(DataSourceConfig.id)))
custom_config_count = int(config_result.scalar() or 0)
tv_payload = await get_tv_settings_payload(db)
tv_sources = tv_payload.get("sources") if isinstance(tv_payload, dict) else []
tv_source_count = len(tv_sources) if isinstance(tv_sources, list) else 0
boundary_status = get_boundary_status()
has_core_layers = bool(boundary_status.get("ready") or boundary_status.get("available") or boundary_status.get("status") in {"ready", "built", "ok"})
has_collected_data = current_record_count > 0
ready = has_collected_data
suggestions: list[str] = []
if not current_user:
suggestions.append("登录控制台")
if not has_collected_data:
suggestions.append("触发数据源采集")
if not custom_config_count:
suggestions.append("确认采集器配置")
if not has_core_layers:
suggestions.append("构建或启用 Earth 图层")
return {
"ready": ready,
"authenticated": current_user is not None,
"needs_login": current_user is None and not ready,
"has_collected_data": has_collected_data,
"has_tv_sources": tv_source_count > 0,
"has_core_layers": has_core_layers,
"current_record_count": current_record_count,
"datasource_count": datasource_count,
"active_datasource_count": active_datasource_count,
"custom_config_count": custom_config_count,
"tv_source_count": tv_source_count,
"suggestions": suggestions,
"login_url": "/login?next=/datasources",
"datasources_url": "/datasources",
"collection_url": "/collection-management",
}
@router.get("/boundaries/status")
async def get_earth_boundary_status():
return get_boundary_status()
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.")

View File

@@ -1,6 +1,6 @@
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.visualization import (
@@ -110,6 +110,7 @@ async def get_vessel_layer_snapshot(
vessel_type: Optional[str] = Query(None, alias="type"),
since_minutes: int = Query(60, ge=1, le=1440),
db: AsyncSession = Depends(get_db),
response: Response = None,
):
parsed_bbox = _parse_layer_bbox(bbox)
return await build_vessel_snapshot_response(
@@ -119,6 +120,7 @@ async def get_vessel_layer_snapshot(
limit=limit,
type_filter=vessel_type,
since_minutes=since_minutes,
response=response,
)

View File

@@ -4,7 +4,8 @@ import os
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
import httpx
from pydantic import BaseModel, EmailStr, Field
from dotenv import dotenv_values
from sqlalchemy import select
@@ -64,10 +65,12 @@ from app.services.llm_provider_catalog import (
)
from app.services.scheduler import sync_datasource_job
from app.services.tv_streams import DEFAULT_TV_SETTINGS, get_tv_settings_payload, normalize_tv_settings
from app.services.persistent_logs import record_audit_log
router = APIRouter()
AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS = 5
AI_CONNECTION_TEST_PROMPT_KEY = "ai.connection_test"
SECRET_REVEAL_ROLES = {"admin", "super_admin"}
DEFAULT_SETTINGS = {
"system": {
@@ -130,6 +133,70 @@ DEFAULT_SETTINGS = {
}
def _user_role_value(user: User) -> str:
role = getattr(user, "role", "")
return role.value if hasattr(role, "value") else str(role or "")
def _user_display_name(user: User) -> str | None:
return getattr(user, "username", None) or getattr(user, "email", None)
def _request_client_ip(request: Request | None) -> str | None:
if request is None or request.client is None:
return None
return request.client.host
def _can_reveal_integration_secrets(user: User) -> bool:
return _user_role_value(user) in SECRET_REVEAL_ROLES
async def _record_integration_secret_reveal(
*,
current_user: User,
request: Request | None,
target_id: str,
result: str,
details: dict,
) -> None:
await record_audit_log(
action="settings.integration_secret.reveal",
actor_id=getattr(current_user, "id", None),
actor_name=_user_display_name(current_user),
target_type="integration_secret",
target_id=target_id,
result=result,
ip=_request_client_ip(request),
details=details,
)
async def _ensure_secret_reveal_allowed(
*,
current_user: User,
request: Request | None,
target_id: str,
details: dict | None = None,
) -> None:
if _can_reveal_integration_secrets(current_user):
return
await _record_integration_secret_reveal(
current_user=current_user,
request=request,
target_id=target_id,
result="denied",
details={
**(details or {}),
"role": _user_role_value(current_user),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can reveal integration secrets",
)
class SystemSettingsUpdate(BaseModel):
system_name: str = "智能星球"
refresh_interval: int = Field(default=60, ge=10, le=3600)
@@ -353,9 +420,10 @@ def _get_provider_preset(provider: str) -> dict:
"provider": provider,
"provider_api": "openai-completions",
"base_url": "",
"model": "",
"models": [],
"api_key_env": "",
"model": "",
"models": [],
"model_provider_apis": {},
"api_key_env": "",
}
@@ -377,6 +445,9 @@ def _resolve_env_secret(*names: str) -> tuple[str, str]:
value = env_file_values.get(name)
if value:
return value, "env_file"
value = os.environ.get(name)
if value:
return value, "env"
return "", ""
@@ -419,9 +490,16 @@ def _provider_defaults(provider: str) -> dict:
1200 if preset.get("provider_api") == "anthropic-messages" else 4096
),
"anthropic_version": "2023-06-01",
"model_provider_apis": preset.get("model_provider_apis") or {},
}
def _selected_ai_env_provider() -> str:
env_file_values = _read_ai_provider_env_file()
provider = env_file_values.get("AI_PROVIDER") or os.environ.get("AI_PROVIDER") or "minimax"
return _normalize_provider_id(provider)
def _normalize_ai_provider_payload(ai_payload: dict | None) -> dict:
raw = dict(ai_payload or {})
default_provider = _normalize_provider_id(raw.get("default_provider") or raw.get("provider"))
@@ -440,6 +518,7 @@ def _normalize_ai_provider_payload(ai_payload: dict | None) -> dict:
"api_key",
"max_tokens",
"anthropic_version",
"model_provider_apis",
)
if raw.get(key) not in (None, "")
}
@@ -477,7 +556,12 @@ def _resolve_provider_api_key(provider: str, provider_config: dict) -> tuple[str
return str(saved_key), "runtime"
preset = _get_provider_preset(provider)
api_key_env = preset.get("api_key_env") or ""
return _resolve_env_secret(api_key_env, "AI_API_KEY")
value, source = _resolve_env_secret(api_key_env)
if value:
return value, source
if _normalize_provider_id(provider) == _selected_ai_env_provider():
return _resolve_env_secret("AI_API_KEY")
return "", ""
def _resolve_service_token(ai_payload: dict) -> tuple[str, str]:
@@ -509,7 +593,12 @@ def _is_secret_placeholder(value: Optional[str], current_preview: str = "") -> b
def _build_ai_provider_payload(current_payload: dict, update: AIProviderIntegrationUpdate) -> dict:
current_ai = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
provider_id = _normalize_provider_id(update.default_provider or update.provider)
provider_id = _normalize_provider_id(update.provider)
default_provider = (
_normalize_provider_id(update.default_provider)
if update.default_provider is not None
else current_ai["default_provider"]
)
current_providers = {
provider: dict(config or {})
for provider, config in current_ai.get("providers", {}).items()
@@ -543,7 +632,7 @@ def _build_ai_provider_payload(current_payload: dict, update: AIProviderIntegrat
"service_url": update.service_url.strip()
or app_settings.AI_PROVIDER_SERVICE_URL,
"service_token": current_ai.get("service_token") or "",
"default_provider": provider_id,
"default_provider": default_provider,
"providers": current_providers,
"timeout_seconds": update.timeout_seconds,
"retry_attempts": update.retry_attempts,
@@ -577,6 +666,8 @@ def _runtime_config_from_ai_payload(ai_payload: dict) -> dict:
"api_key": api_key,
"max_tokens": int(provider_config.get("max_tokens") or 1200),
"anthropic_version": provider_config.get("anthropic_version") or "2023-06-01",
"model_provider_apis": provider_config.get("model_provider_apis") or {},
"preset_models": _get_provider_preset(default_provider).get("models") or [],
},
}
@@ -631,6 +722,159 @@ async def _validate_ai_provider_full_connection(ai_payload: dict) -> dict:
}
def _join_provider_url(base_url: str, path: str) -> str:
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
def _extract_model_ids(payload: dict) -> list[str]:
data = payload.get("data") if isinstance(payload, dict) else None
if isinstance(data, list):
return [
str(item.get("id"))
for item in data
if isinstance(item, dict) and item.get("id")
]
models = payload.get("models") if isinstance(payload, dict) else None
if isinstance(models, list):
return [
str(item.get("name") or item.get("model") or item.get("id") or item)
for item in models
if item
]
return []
def _contains_model(model_ids: list[str], model: str) -> bool:
normalized_model = model.strip().lower()
return any(str(item).strip().lower() == normalized_model for item in model_ids)
async def _check_ai_provider_lightweight(llm_config: dict, timeout_seconds: int) -> dict:
provider = _normalize_provider_id(llm_config.get("provider") or "")
configured_api = str(llm_config.get("provider_api") or "").strip() or "openai-completions"
model = str(llm_config.get("model") or "").strip()
base_url = str(llm_config.get("base_url") or "").strip().rstrip("/")
api_key = str(llm_config.get("api_key") or "").strip()
provider_api = configured_api
model_provider_apis = llm_config.get("model_provider_apis")
if isinstance(model_provider_apis, dict):
provider_api = str(model_provider_apis.get(model) or provider_api)
preset_models = [
str(item)
for item in (llm_config.get("preset_models") or [])
if str(item).strip()
]
if not provider or not base_url or not model:
return {
"success": False,
"connected": False,
"message": "当前 provider/base_url/model 未完整配置。",
"mode": "lightweight_config",
}
if provider_api != "ollama-generate" and not api_key:
return {
"success": False,
"connected": False,
"message": "当前 provider 未配置 API Key。",
"mode": "lightweight_config",
}
if provider == "opencode-go":
url = _join_provider_url(base_url, "/models")
headers = {"Authorization": f"Bearer {api_key}"}
elif provider_api == "ollama-generate":
url = _join_provider_url(base_url, "/api/tags")
headers: dict[str, str] = {}
elif provider_api == "openai-completions":
url = _join_provider_url(base_url, "/models")
headers = {"Authorization": f"Bearer {api_key}"}
elif provider_api == "anthropic-messages":
url = _join_provider_url(base_url, "/models")
headers = {
"x-api-key": api_key,
"anthropic-version": str(llm_config.get("anthropic_version") or "2023-06-01"),
}
else:
return {
"success": False,
"connected": False,
"message": f"当前 provider_api 不支持轻量连通性测试: {provider_api}",
"mode": "lightweight_unsupported",
}
try:
async with httpx.AsyncClient(timeout=min(timeout_seconds, AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS)) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
payload = response.json()
except httpx.HTTPStatusError as exc:
detail = exc.response.text or exc.response.reason_phrase
if exc.response.status_code == 404 and _contains_model(preset_models, model):
return {
"success": True,
"connected": True,
"message": "轻量连通性测试通过;当前 provider 不提供可用的模型目录,已按内置模型预设确认。",
"mode": "lightweight_preset",
"provider": provider,
"provider_api": provider_api,
"model": model,
"url": url,
}
return {
"success": False,
"connected": False,
"message": f"轻量连通性测试失败: HTTP {exc.response.status_code} {detail}",
"mode": "lightweight_models",
"url": url,
}
except Exception as exc:
return {
"success": False,
"connected": False,
"message": f"轻量连通性测试失败: {exc}",
"mode": "lightweight_models",
"url": url,
}
model_ids = _extract_model_ids(payload)
if model_ids and not _contains_model(model_ids, model):
if _contains_model(preset_models, model):
return {
"success": True,
"connected": True,
"message": "轻量连通性测试通过provider 模型目录未返回当前别名,已按内置模型预设确认。",
"mode": "lightweight_models_with_preset_alias",
"provider": provider,
"provider_api": provider_api,
"model": model,
"models_count": len(model_ids),
"url": url,
}
return {
"success": False,
"connected": False,
"message": f"连接可用,但模型目录中没有当前模型: {model}",
"mode": "lightweight_models",
"provider": provider,
"model": model,
"models_count": len(model_ids),
"url": url,
}
return {
"success": True,
"connected": True,
"message": "轻量连通性测试通过",
"mode": "lightweight_models",
"provider": provider,
"provider_api": provider_api,
"model": model,
"models_count": len(model_ids),
"url": url,
}
def _web_search_provider_defaults(provider: str) -> dict:
return web_search_provider_defaults(provider).model_dump()
@@ -691,12 +935,21 @@ def _normalize_web_search_payload(web_search_payload: dict | None) -> dict:
}
def _resolve_web_search_api_key(provider: str, provider_config: dict) -> tuple[str, str]:
def _resolve_web_search_api_key(
provider: str,
provider_config: dict,
default_provider: str | None = None,
) -> tuple[str, str]:
saved_key = provider_config.get("api_key") or ""
if saved_key:
return str(saved_key), "runtime"
preset = get_web_search_provider_preset(provider)
return _resolve_web_search_env_secret(preset.get("api_key_env") or "", "WEB_SEARCH_API_KEY")
value, source = _resolve_web_search_env_secret(preset.get("api_key_env") or "")
if value:
return value, source
if normalize_web_search_provider(provider) == normalize_web_search_provider(default_provider or "tavily"):
return _resolve_web_search_env_secret("WEB_SEARCH_API_KEY")
return "", ""
def _build_web_search_payload(
@@ -706,13 +959,22 @@ def _build_web_search_payload(
current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {})
if update is None:
return current_web_search
provider_id = normalize_web_search_provider(update.default_provider or update.provider)
provider_id = normalize_web_search_provider(update.provider)
default_provider = (
normalize_web_search_provider(update.default_provider)
if update.default_provider is not None
else current_web_search["default_provider"]
)
current_providers = {
provider: dict(config or {})
for provider, config in current_web_search.get("providers", {}).items()
}
current_provider = current_providers.get(provider_id) or _web_search_provider_defaults(provider_id)
current_key, current_key_source = _resolve_web_search_api_key(provider_id, current_provider)
current_key, current_key_source = _resolve_web_search_api_key(
provider_id,
current_provider,
current_web_search["default_provider"],
)
current_key_preview = _mask_secret(current_key, current_key_source)["preview"]
provider_payload = {
**_web_search_provider_defaults(provider_id),
@@ -745,7 +1007,7 @@ def _build_web_search_payload(
current_providers[provider_id] = provider_payload
return {
"enabled": update.enabled,
"default_provider": provider_id,
"default_provider": default_provider,
"providers": current_providers,
}
@@ -754,12 +1016,12 @@ def _runtime_config_from_web_search_payload(web_search_payload: dict) -> WebSear
normalized = _normalize_web_search_payload(web_search_payload)
provider_id = normalized["default_provider"]
provider_config = normalized["providers"].get(provider_id) or _web_search_provider_defaults(provider_id)
api_key, _source = _resolve_web_search_api_key(provider_id, provider_config)
api_key, _source = _resolve_web_search_api_key(provider_id, provider_config, provider_id)
provider_models = {
provider: WebSearchProviderConfig(**{
**config,
"api_key": (
api_key if provider == provider_id else _resolve_web_search_api_key(provider, config)[0]
api_key if provider == provider_id else _resolve_web_search_api_key(provider, config, provider_id)[0]
),
})
for provider, config in normalized["providers"].items()
@@ -893,7 +1155,11 @@ async def serialize_external_integrations(db: AsyncSession) -> dict:
normalized_web_search["providers"].get(provider_id)
or _web_search_provider_defaults(provider_id)
)
api_key, api_key_source = _resolve_web_search_api_key(provider_id, provider_config)
api_key, api_key_source = _resolve_web_search_api_key(
provider_id,
provider_config,
normalized_web_search["default_provider"],
)
web_search_providers_payload[provider_id] = {
**{
key: value
@@ -980,10 +1246,7 @@ 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)
@@ -1331,6 +1594,10 @@ async def connect_ai_provider_integration(
db: AsyncSession = Depends(get_db),
):
current_payload = await get_setting_payload(db, "external_integrations")
# Connection testing should validate the provider being edited, not the
# currently saved default provider. This is a transient draft only and is
# intentionally not persisted.
payload = payload.model_copy(update={"default_provider": payload.provider})
draft_ai_payload = _build_ai_provider_payload(current_payload, payload)
runtime_config = _runtime_config_from_ai_payload(draft_ai_payload)
quick_llm_config = {
@@ -1354,24 +1621,16 @@ async def connect_ai_provider_integration(
"message": "AI Provider 可访问,但当前 provider/model/key 未完整配置。",
"status": status_result.model_dump(),
}
prompt = await get_effective_prompt(db, AI_CONNECTION_TEST_PROMPT_KEY)
probe_result = await client.analyze(
SituationalAnalysisRequest(
title="快速连接测试",
objective=prompt.prompt,
system_prompt=prompt.system_prompt or None,
observations=[],
constraints=["Output only OK."],
)
lightweight_result = await _check_ai_provider_lightweight(
quick_llm_config,
timeout_seconds=min(
int(runtime_config["timeout_seconds"] or 60),
AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS,
),
)
return {
"success": True,
"connected": True,
"message": "连接测试通过",
**lightweight_result,
"status": status_result.model_dump(),
"provider": probe_result.provider,
"model": probe_result.model,
"mode": "quick_probe",
}
except HTTPException as exc:
return {
@@ -1389,16 +1648,38 @@ async def connect_ai_provider_integration(
@router.get("/integrations/ai-provider/secrets")
async def reveal_ai_provider_secrets(
request: Request,
provider: str = Query(default=""),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
requested_provider = _normalize_provider_id(provider) if provider else "default"
await _ensure_secret_reveal_allowed(
current_user=current_user,
request=request,
target_id=f"ai_provider:{requested_provider}",
details={"kind": "ai_provider", "provider": requested_provider},
)
current_payload = await get_setting_payload(db, "external_integrations")
ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
provider_id = _normalize_provider_id(provider or ai_payload["default_provider"])
provider_config = ai_payload["providers"].get(provider_id) or _provider_defaults(provider_id)
api_key, api_key_source = _resolve_provider_api_key(provider_id, provider_config)
service_token, service_token_source = _resolve_service_token(ai_payload)
await _record_integration_secret_reveal(
current_user=current_user,
request=request,
target_id=f"ai_provider:{provider_id}",
result="success",
details={
"kind": "ai_provider",
"provider": provider_id,
"api_key_configured": bool(api_key),
"api_key_source": api_key_source,
"service_token_configured": bool(service_token),
"service_token_source": service_token_source,
},
)
return {
"provider": provider_id,
"api_key": api_key,
@@ -1417,10 +1698,18 @@ async def get_web_search_presets(
@router.get("/integrations/web-search/secrets")
async def reveal_web_search_secrets(
request: Request,
provider: str = Query(default=""),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
requested_provider = normalize_web_search_provider(provider) if provider else "default"
await _ensure_secret_reveal_allowed(
current_user=current_user,
request=request,
target_id=f"web_search:{requested_provider}",
details={"kind": "web_search", "provider": requested_provider},
)
current_payload = await get_setting_payload(db, "external_integrations")
web_search_payload = _normalize_web_search_payload(current_payload.get("web_search") or {})
provider_id = normalize_web_search_provider(provider or web_search_payload["default_provider"])
@@ -1428,7 +1717,23 @@ async def reveal_web_search_secrets(
web_search_payload["providers"].get(provider_id)
or _web_search_provider_defaults(provider_id)
)
api_key, api_key_source = _resolve_web_search_api_key(provider_id, provider_config)
api_key, api_key_source = _resolve_web_search_api_key(
provider_id,
provider_config,
web_search_payload["default_provider"],
)
await _record_integration_secret_reveal(
current_user=current_user,
request=request,
target_id=f"web_search:{provider_id}",
result="success",
details={
"kind": "web_search",
"provider": provider_id,
"api_key_configured": bool(api_key),
"api_key_source": api_key_source,
},
)
return {
"provider": provider_id,
"api_key": api_key,
@@ -1438,12 +1743,31 @@ async def reveal_web_search_secrets(
@router.get("/integrations/ocr/secrets")
async def reveal_ocr_secrets(
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await _ensure_secret_reveal_allowed(
current_user=current_user,
request=request,
target_id="ocr:default",
details={"kind": "ocr", "provider": "default"},
)
current_payload = await get_setting_payload(db, "external_integrations")
ocr_payload = _normalize_ocr_payload(current_payload.get("ocr") or {})
api_key, api_key_source = _resolve_ocr_api_key(ocr_payload)
await _record_integration_secret_reveal(
current_user=current_user,
request=request,
target_id=f"ocr:{ocr_payload['provider']}",
result="success",
details={
"kind": "ocr",
"provider": ocr_payload["provider"],
"api_key_configured": bool(api_key),
"api_key_source": api_key_source,
},
)
return {
"provider": ocr_payload["provider"],
"api_key": api_key,
@@ -1547,9 +1871,17 @@ async def get_ai_provider_presets(
async def refresh_ai_provider_preset(
provider: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
return {"data": await refresh_llm_provider_preset(provider)}
provider_id = _normalize_provider_id(provider)
api_key = None
if provider_id == "opencode-go":
current_payload = await get_setting_payload(db, "external_integrations")
ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
provider_config = ai_payload["providers"].get(provider_id) or _provider_defaults(provider_id)
api_key, _api_key_source = _resolve_provider_api_key(provider_id, provider_config)
return {"data": await refresh_llm_provider_preset(provider_id, api_key=api_key)}
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:

View File

@@ -8,9 +8,13 @@ from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import ROOT_DIR
from app.core.security import get_current_user
from app.db.session import get_db
from app.models.system_log import AuditLog, SystemLog
from app.models.user import User
from app.services.persistent_logs import record_audit_log, record_system_log
from app.services.system_control import (
@@ -35,6 +39,7 @@ from app.services.system_logs import (
normalize_log_level,
read_log_snapshot,
)
from app.services.earth_layer_cache import earth_layer_cache
router = APIRouter()
@@ -112,6 +117,17 @@ class EarthClientLogEventResponse(BaseModel):
level: str
class EarthLayerCacheStatusResponse(BaseModel):
prefix: str
key_count: int
memory_bytes: int
layers: dict[str, dict[str, int]]
class EarthLayerCacheClearResponse(BaseModel):
deleted: int
def ensure_super_admin(current_user: User) -> None:
if not require_super_admin(current_user.role):
raise HTTPException(
@@ -132,6 +148,34 @@ def validate_log_date(raw_value: str | None, field_name: str) -> str | None:
) from exc
@router.get("/cache/earth-layers", response_model=EarthLayerCacheStatusResponse)
async def get_earth_layer_cache_status(
current_user: User = Depends(get_current_user),
):
ensure_super_admin(current_user)
try:
return earth_layer_cache.status()
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Unable to read Earth layer cache status: {exc}",
) from exc
@router.delete("/cache/earth-layers", response_model=EarthLayerCacheClearResponse)
async def clear_earth_layer_cache(
current_user: User = Depends(get_current_user),
):
ensure_super_admin(current_user)
try:
return {"deleted": earth_layer_cache.delete_pattern()}
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Unable to clear Earth layer cache: {exc}",
) from exc
@router.post("/restart-tasks", response_model=RestartTaskResponse)
async def create_restart_task(
payload: RestartTaskCreate,
@@ -270,7 +314,120 @@ async def get_system_log_sources(
current_user: User = Depends(get_current_user),
):
ensure_super_admin(current_user)
return {"items": list_log_sources()}
return {
"items": [
*list_log_sources(),
{
"source_id": "system-db",
"name": "系统事件",
"kind": "database",
"location": "table://system_logs",
"description": "后端持久化系统事件、AI 和采集器操作日志。",
"category": "database",
"status": "ok",
},
{
"source_id": "audit-db",
"name": "审计事件",
"kind": "database",
"location": "table://audit_logs",
"description": "管理员敏感操作和密钥 reveal 审计记录。",
"category": "audit",
"status": "ok",
},
]
}
async def read_database_log_snapshot(
source_id: str,
*,
limit: int,
level: str,
levels: str | None,
start_date: str | None,
end_date: str | None,
search: str | None,
db: AsyncSession,
) -> dict | None:
selected_levels = set(normalize_log_level(item) for item in (levels or level).split(",") if item.strip())
selected_levels.discard("all")
search_query = (search or "").strip().lower()
lines: list[str] = []
if source_id == "system-db":
query = select(SystemLog).order_by(SystemLog.occurred_at.desc().nullslast(), SystemLog.id.desc()).limit(limit * 5)
result = await db.execute(query)
records = result.scalars().all()
for record in records:
record_level = normalize_log_level(record.level)
if selected_levels and record_level not in selected_levels:
continue
occurred_at = record.occurred_at.date().isoformat() if record.occurred_at else ""
if start_date and occurred_at and occurred_at < start_date:
continue
if end_date and occurred_at and occurred_at > end_date:
continue
line = " ".join(
part
for part in [
record.occurred_at.isoformat() if record.occurred_at else "",
record_level.upper(),
record.source,
record.event or "",
record.message,
]
if part
)
if search_query and search_query not in line.lower():
continue
lines.append(line)
elif source_id == "audit-db":
query = select(AuditLog).order_by(AuditLog.occurred_at.desc().nullslast(), AuditLog.id.desc()).limit(limit * 5)
result = await db.execute(query)
records = result.scalars().all()
for record in records:
occurred_at = record.occurred_at.date().isoformat() if record.occurred_at else ""
if start_date and occurred_at and occurred_at < start_date:
continue
if end_date and occurred_at and occurred_at > end_date:
continue
line = " ".join(
part
for part in [
record.occurred_at.isoformat() if record.occurred_at else "",
"INFO",
record.action,
record.target_type or "",
record.target_id or "",
record.result or "",
]
if part
)
if search_query and search_query not in line.lower():
continue
lines.append(line)
else:
return None
lines = list(reversed(lines[:limit]))
return {
"source_id": source_id,
"name": "系统事件" if source_id == "system-db" else "审计事件",
"kind": "database",
"location": "table://system_logs" if source_id == "system-db" else "table://audit_logs",
"description": "数据库持久化日志",
"category": "database" if source_id == "system-db" else "audit",
"status": "ok" if lines else "empty",
"level": level,
"selected_levels": sorted(selected_levels),
"search_query": search or "",
"available_levels": ["all", "error", "warning", "info", "debug"],
"daily_markers": [],
"line_limit": limit,
"line_count": len(lines),
"lines": lines,
}
@router.get("/logs/{source_id}", response_model=SystemLogSnapshotResponse)
@@ -283,6 +440,7 @@ async def get_system_log_snapshot(
end_date: str | None = Query(None, description="Filter logs until this date (YYYY-MM-DD)"),
search: str | None = Query(None, description="Case-insensitive substring search"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
ensure_super_admin(current_user)
@@ -305,15 +463,26 @@ async def get_system_log_snapshot(
if normalized_start_date and normalized_end_date and normalized_start_date > normalized_end_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="start_date must be earlier than or equal to end_date")
snapshot = read_log_snapshot(
snapshot = await read_database_log_snapshot(
source_id,
limit,
limit=limit,
level=level,
levels=levels,
start_date=normalized_start_date,
end_date=normalized_end_date,
search=search,
db=db,
)
if snapshot is None:
snapshot = read_log_snapshot(
source_id,
limit,
level=level,
levels=levels,
start_date=normalized_start_date,
end_date=normalized_end_date,
search=search,
)
if snapshot is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Log source not found")
return snapshot

View File

@@ -2,7 +2,7 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.visualization import _parse_bbox, build_vessel_snapshot_response
@@ -23,6 +23,7 @@ async def get_vessel_snapshot(
limit: int = Query(1000, ge=1, le=MAX_SNAPSHOT_LIMIT),
since_minutes: int = Query(60, ge=1, le=1440),
db: AsyncSession = Depends(get_db),
response: Response = None,
):
if not bbox:
raise HTTPException(status_code=400, detail="bbox is required")
@@ -36,4 +37,5 @@ async def get_vessel_snapshot(
type_filter=type,
limit=limit,
since_minutes=since_minutes,
response=response,
)

View File

@@ -56,6 +56,13 @@ from app.services.vessel_ais_aggregation import (
get_vessel_raw_observations,
MAX_SNAPSHOT_LIMIT,
)
from app.services.earth_layer_cache import (
EarthLayerCachePolicy,
earth_layer_cache,
format_bbox_key,
get_or_build_layer_payload,
quantize_bbox,
)
from app.core.logging import get_logger
router = APIRouter()
@@ -69,6 +76,68 @@ TERRAIN_TILE_BATCH_CONCURRENCY = 16
_terrain_tile_cache: OrderedDict[tuple[int, int, int], tuple[bytes, str, dict[str, str]]] = OrderedDict()
VESSEL_NAME_FALLBACK_PATTERN = re.compile(r"^mmsi\s*\d+$", re.IGNORECASE)
VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED = True
SECONDS_PER_MINUTE = 60
BYTES_PER_MIB = 1024 * 1024
CABLE_CACHE_FRESH_SECONDS = 6 * 60 * SECONDS_PER_MINUTE
CABLE_CACHE_STALE_SECONDS = 24 * 60 * SECONDS_PER_MINUTE
SATELLITE_CACHE_FRESH_SECONDS = 15 * SECONDS_PER_MINUTE
SATELLITE_CACHE_STALE_SECONDS = 2 * 60 * SECONDS_PER_MINUTE
COMPUTE_CENTER_CACHE_FRESH_SECONDS = 10 * SECONDS_PER_MINUTE
COMPUTE_CENTER_CACHE_STALE_SECONDS = 60 * SECONDS_PER_MINUTE
BGP_CACHE_FRESH_SECONDS = 60
BGP_EVENT_CACHE_FRESH_SECONDS = 30
BGP_CACHE_STALE_SECONDS = 10 * SECONDS_PER_MINUTE
VESSEL_SNAPSHOT_CACHE_FRESH_SECONDS = 5
VESSEL_SNAPSHOT_CACHE_STALE_SECONDS = 30
CABLE_CACHE_POLICY = EarthLayerCachePolicy(
CABLE_CACHE_FRESH_SECONDS,
CABLE_CACHE_STALE_SECONDS,
max_features=6000,
max_bytes=10 * BYTES_PER_MIB,
)
LANDING_POINT_CACHE_POLICY = EarthLayerCachePolicy(
CABLE_CACHE_FRESH_SECONDS,
CABLE_CACHE_STALE_SECONDS,
max_features=6000,
max_bytes=8 * BYTES_PER_MIB,
)
SATELLITE_CACHE_POLICY = EarthLayerCachePolicy(
SATELLITE_CACHE_FRESH_SECONDS,
SATELLITE_CACHE_STALE_SECONDS,
max_features=8000,
max_bytes=10 * BYTES_PER_MIB,
)
COMPUTE_CENTER_CACHE_POLICY = EarthLayerCachePolicy(
COMPUTE_CENTER_CACHE_FRESH_SECONDS,
COMPUTE_CENTER_CACHE_STALE_SECONDS,
max_features=1000,
max_bytes=4 * BYTES_PER_MIB,
)
BGP_CACHE_POLICY = EarthLayerCachePolicy(
BGP_CACHE_FRESH_SECONDS,
BGP_CACHE_STALE_SECONDS,
max_features=1000,
max_bytes=3 * BYTES_PER_MIB,
)
BGP_EVENT_CACHE_POLICY = EarthLayerCachePolicy(
BGP_EVENT_CACHE_FRESH_SECONDS,
BGP_CACHE_STALE_SECONDS,
max_features=1000,
max_bytes=3 * BYTES_PER_MIB,
)
SUMMARY_CACHE_POLICY = EarthLayerCachePolicy(
BGP_EVENT_CACHE_FRESH_SECONDS,
BGP_CACHE_STALE_SECONDS,
max_features=0,
max_bytes=512 * 1024,
)
VESSEL_SNAPSHOT_CACHE_POLICY = EarthLayerCachePolicy(
VESSEL_SNAPSHOT_CACHE_FRESH_SECONDS,
VESSEL_SNAPSHOT_CACHE_STALE_SECONDS,
max_features=1500,
max_bytes=3 * BYTES_PER_MIB,
)
class TerrariumTileRequest(BaseModel):
@@ -1010,7 +1079,40 @@ async def build_vessel_snapshot_response(
type_filter: str | None,
limit: int | None,
since_minutes: int = 60,
response: Response | None = None,
use_cache: bool = True,
) -> dict[str, Any]:
if use_cache and bbox is not None:
safe_limit_for_key = _safe_vessel_limit(limit)
safe_since_for_key = min(max(int(since_minutes or 60), 1), 1440)
cache_key = earth_layer_cache.key(
"vessels-snapshot",
bbox=format_bbox_key(quantize_bbox(bbox)),
zoom=zoom or "none",
type=type_filter or "all",
limit=safe_limit_for_key,
since=safe_since_for_key,
)
async def build_uncached() -> dict[str, Any]:
return await build_vessel_snapshot_response(
db,
bbox=bbox,
zoom=zoom,
type_filter=type_filter,
limit=limit,
since_minutes=since_minutes,
response=None,
use_cache=False,
)
return await get_or_build_layer_payload(
key=cache_key,
policy=VESSEL_SNAPSHOT_CACHE_POLICY,
builder=build_uncached,
response=response,
)
requested_types = _requested_vessel_types(type_filter)
safe_limit = _safe_vessel_limit(limit)
safe_since_minutes = min(max(int(since_minutes or 60), 1), 1440)
@@ -1444,8 +1546,20 @@ def convert_bgp_incidents_to_geojson(
@router.get("/geo/cables")
async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
async def get_cables_geojson(db: AsyncSession = Depends(get_db), response: Response = None):
"""获取海底电缆 GeoJSON 数据 (LineString)"""
async def build_payload() -> dict[str, Any]:
return await _build_cables_geojson(db)
return await get_or_build_layer_payload(
key=earth_layer_cache.key("cables"),
policy=CABLE_CACHE_POLICY,
builder=build_payload,
response=response,
)
async def _build_cables_geojson(db: AsyncSession) -> dict[str, Any]:
try:
records = await _load_current_collected_data(db, "arcgis_cables")
@@ -1478,7 +1592,19 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
@router.get("/geo/landing-points")
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db), response: Response = None):
async def build_payload() -> dict[str, Any]:
return await _build_landing_points_geojson(db)
return await get_or_build_layer_payload(
key=earth_layer_cache.key("landing-points"),
policy=LANDING_POINT_CACHE_POLICY,
builder=build_payload,
response=response,
)
async def _build_landing_points_geojson(db: AsyncSession) -> dict[str, Any]:
try:
records_by_source = await _load_current_collected_data_by_sources(
db,
@@ -1731,8 +1857,25 @@ async def get_satellites_geojson(
description="Maximum number of satellites to return. Omit for no limit.",
),
db: AsyncSession = Depends(get_db),
response: Response = None,
):
"""获取卫星 TLE GeoJSON 数据"""
async def build_payload() -> dict[str, Any]:
return await _build_satellites_geojson(limit=limit, db=db)
return await get_or_build_layer_payload(
key=earth_layer_cache.key("satellites", limit=limit or "all"),
policy=SATELLITE_CACHE_POLICY,
builder=build_payload,
response=response,
)
async def _build_satellites_geojson(
*,
limit: int | None,
db: AsyncSession,
) -> dict[str, Any]:
records = await _load_current_or_latest_task_data(
db,
"celestrak_tle",
@@ -1800,8 +1943,25 @@ async def get_gpu_clusters_geojson(
async def get_compute_centers_geojson(
limit: int = Query(200, ge=1, le=1000),
db: AsyncSession = Depends(get_db),
response: Response = None,
):
"""获取统一算力中心 GeoJSON 数据"""
async def build_payload() -> dict[str, Any]:
return await _build_compute_centers_geojson(limit=limit, db=db)
return await get_or_build_layer_payload(
key=earth_layer_cache.key("compute-centers", limit=limit),
policy=COMPUTE_CENTER_CACHE_POLICY,
builder=build_payload,
response=response,
)
async def _build_compute_centers_geojson(
*,
limit: int,
db: AsyncSession,
) -> dict[str, Any]:
records_by_source = await _load_current_collected_data_by_sources(
db,
["top500", "epoch_ai_gpu"],
@@ -1999,6 +2159,24 @@ async def collect_compute_center_location(
llm_failure_reason = llm_result.failure_reason
if not candidates:
logger.warning_event(
"Compute center location collection returned no candidates",
event="visualization.compute_center.location_collect.completed",
context={
"source_id": source_id,
"record_id": record_id,
"name": name,
"success": False,
"llm_failure_reason": llm_failure_reason,
"attempted_queries": list(attempted_queries),
"context": {
"operator": operator,
"site": site,
"city": city,
"country": country,
},
},
)
return {
"source_id": source_id,
"record_id": record_id,
@@ -2020,13 +2198,34 @@ async def collect_compute_center_location(
},
}
best_candidate = candidates[0].to_dict()
logger.info_event(
"Compute center location collection returned candidates",
event="visualization.compute_center.location_collect.completed",
context={
"source_id": source_id,
"record_id": record_id,
"name": name,
"success": True,
"candidate_count": len(candidates),
"best_candidate": best_candidate,
"llm_failure_reason": llm_failure_reason,
"attempted_queries": list(attempted_queries),
"context": {
"operator": operator,
"site": site,
"city": city,
"country": country,
},
},
)
return {
"source_id": source_id,
"record_id": record_id,
"name": name,
"success": True,
"candidates": [candidate.to_dict() for candidate in candidates],
"best_candidate": candidates[0].to_dict(),
"best_candidate": best_candidate,
"attempted_queries": list(attempted_queries),
"context": {
"name": name,
@@ -2397,7 +2596,31 @@ async def get_bgp_anomalies_geojson(
status: Optional[str] = Query("active"),
limit: int = Query(200, ge=1, le=1000),
db: AsyncSession = Depends(get_db),
response: Response = None,
):
async def build_payload() -> dict[str, Any]:
return await _build_bgp_anomalies_geojson(
severity=severity,
status=status,
limit=limit,
db=db,
)
return await get_or_build_layer_payload(
key=earth_layer_cache.key("bgp-anomalies", severity=severity or "all", status=status or "all", limit=limit),
policy=BGP_EVENT_CACHE_POLICY,
builder=build_payload,
response=response,
)
async def _build_bgp_anomalies_geojson(
*,
severity: str | None,
status: str | None,
limit: int,
db: AsyncSession,
) -> dict[str, Any]:
stmt = select(BGPAnomaly).order_by(BGPAnomaly.created_at.desc()).limit(limit)
if severity:
stmt = stmt.where(BGPAnomaly.severity == severity)
@@ -2417,7 +2640,31 @@ async def get_bgp_incidents_geojson(
status: Optional[str] = Query("active"),
limit: int = Query(100, ge=1, le=500),
db: AsyncSession = Depends(get_db),
response: Response = None,
):
async def build_payload() -> dict[str, Any]:
return await _build_bgp_incidents_geojson(
severity=severity,
status=status,
limit=limit,
db=db,
)
return await get_or_build_layer_payload(
key=earth_layer_cache.key("bgp-incidents", severity=severity or "all", status=status or "all", limit=limit),
policy=BGP_EVENT_CACHE_POLICY,
builder=build_payload,
response=response,
)
async def _build_bgp_incidents_geojson(
*,
severity: str | None,
status: str | None,
limit: int,
db: AsyncSession,
) -> dict[str, Any]:
stmt = select(BGPIncident).order_by(BGPIncident.created_at.desc()).limit(limit)
if severity:
stmt = stmt.where(BGPIncident.severity == severity)
@@ -2432,7 +2679,19 @@ async def get_bgp_incidents_geojson(
@router.get("/geo/bgp-collectors")
async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db)):
async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db), response: Response = None):
async def build_payload() -> dict[str, Any]:
return await _build_bgp_collectors_geojson(db)
return await get_or_build_layer_payload(
key=earth_layer_cache.key("bgp-collectors"),
policy=BGP_CACHE_POLICY,
builder=build_payload,
response=response,
)
async def _build_bgp_collectors_geojson(db: AsyncSession) -> dict[str, Any]:
coverage = await build_bgp_collector_coverage(
db,
source_filter=("ris_live_bgp", "bgpstream_bgp"),
@@ -2447,8 +2706,20 @@ async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db)):
@router.get("/geo/summary")
async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db), response: Response = None):
"""Return lightweight Earth HUD counts without loading layer GeoJSON payloads."""
async def build_payload() -> dict[str, Any]:
return await _build_visualization_geo_summary(db)
return await get_or_build_layer_payload(
key=earth_layer_cache.key("summary"),
policy=SUMMARY_CACHE_POLICY,
builder=build_payload,
response=response,
)
async def _build_visualization_geo_summary(db: AsyncSession) -> dict[str, Any]:
cable_count = await _count_current_or_latest_task_data(db, "arcgis_cables")
landing_point_count = await _count_current_or_latest_task_data(db, "arcgis_landing_points")
satellite_count = await _count_current_or_latest_task_data(

View File

@@ -14,6 +14,7 @@ from app.core.websocket.manager import manager
logger = get_logger(__name__, service="api")
router = APIRouter()
EARTH_UPDATES_CHANNEL = "earth_updates"
async def authenticate_token(token: str) -> Optional[dict]:
@@ -58,7 +59,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", "earth_news"] if is_anonymous else [
supported_channels = ["vessels", "earth_news", EARTH_UPDATES_CHANNEL] if is_anonymous else [
"gpu_clusters",
"submarine_cables",
"ixp_nodes",
@@ -67,6 +68,7 @@ async def websocket_endpoint(
"datasource_tasks",
"vessels",
"earth_news",
EARTH_UPDATES_CHANNEL,
]
await manager.connect(websocket, user_id)

View File

@@ -8,6 +8,8 @@ from app.core.time import to_iso8601_utc
from app.core.websocket.manager import manager
EARTH_UPDATES_CHANNEL = "earth_updates"
class DataBroadcaster:
"""Periodically broadcasts data to connected WebSocket clients"""
@@ -83,6 +85,10 @@ class DataBroadcaster:
channel=channel,
)
async def broadcast_earth_update(self, data: Dict[str, Any]):
"""Broadcast Earth visualization refresh hints to connected clients."""
await self.broadcast_custom(EARTH_UPDATES_CHANNEL, data)
def enqueue_vessel_update(self, data: Dict[str, Any]):
vessels = data.get("vessels") if isinstance(data, dict) else None
if not isinstance(vessels, list):

View File

@@ -150,7 +150,7 @@ DEFAULT_LOGIN_USERS = (
{
"username": "linkong",
"email": "linkong@planet.local",
"password": "12345678",
"password": "LK12345678",
"role": "super_admin",
},
)

View File

@@ -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
@@ -88,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():

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import json
import httpx
from fastapi import Depends, HTTPException, status
@@ -57,6 +58,9 @@ class AIProviderClient:
value = self.llm_config.get(key)
if value not in (None, ""):
headers[header_name] = str(value)
model_provider_apis = self.llm_config.get("model_provider_apis")
if isinstance(model_provider_apis, dict) and model_provider_apis:
headers["X-AI-Model-Provider-APIs"] = json.dumps(model_provider_apis)
return headers
async def get_status(self, request_id: str | None = None) -> AIProviderStatusResponse:

View File

@@ -254,6 +254,7 @@ async def build_bgp_brief_request(
system_prompt=prompt.system_prompt or None,
observations=observations_lines,
constraints=[
"直接输出中文 Markdown 简报正文,不要输出英文写作计划、提示词复述、字段说明或元评论。",
"明确区分事实、推断与建议。",
"优先指出需要立即关注的高严重度 incident 或异常模式。",
"需要单独指出哪些区域结论来自 prefix geography / affected regions哪些可能受 collector coverage 偏差影响。",

View File

@@ -9,10 +9,37 @@ from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.collected_data_fields import build_dynamic_metadata, get_record_field
from app.core.config import settings
from app.core.countries import normalize_country
from app.core.time import to_iso8601_utc
from app.core.websocket.broadcaster import broadcaster
from app.services.earth_layer_cache import invalidate_earth_layer_cache_for_source
EARTH_UPDATE_LAYER_HINTS: dict[str, list[str]] = {
"ris_live_bgp": ["bgp"],
"bgpstream_bgp": ["bgp"],
"top500_supercomputers": ["computeCenters"],
"epoch_ai_gpu": ["computeCenters"],
"huggingface_models": ["computeCenters"],
"huggingface_datasets": ["computeCenters"],
"huggingface_spaces": ["computeCenters"],
"telegeography_cables": ["cables"],
"telegeography_landing_points": ["cables"],
"telegeography_cable_systems": ["cables"],
"arcgis_cables": ["cables"],
"fao_landing_points": ["cables"],
"arcgis_landing_points": ["cables"],
"arcgis_cable_landing_relations": ["cables"],
"spacetrack_tle": ["satellites"],
"celestrak_tle": ["satellites"],
"barentswatch_vessels": ["vessels"],
"aisstream_vessels": ["vessels"],
"news_live_streams": ["media"],
"media_news_archive": ["news"],
}
def get_earth_update_layers_for_source(source: str) -> list[str]:
return EARTH_UPDATE_LAYER_HINTS.get(source, [])
class BaseCollector(ABC):
@@ -69,6 +96,29 @@ class BaseCollector(ABC):
)
self._last_broadcast_progress = rounded_progress
async def _publish_earth_update(
self,
*,
action: str,
records_processed: int,
task_id: int | None = None,
) -> None:
layers = get_earth_update_layers_for_source(self.name)
if not layers:
return
await broadcaster.broadcast_earth_update(
{
"action": action,
"source": self.name,
"data_type": self.data_type,
"layers": layers,
"datasource_id": getattr(self, "_datasource_id", None),
"task_id": task_id,
"records_processed": records_processed,
"timestamp": to_iso8601_utc(datetime.now(UTC)),
}
)
async def update_progress(self, records_processed: int, *, commit: bool = False, force: bool = False):
"""Update task progress - call this during data processing"""
if self._current_task and self._db_session:
@@ -186,7 +236,7 @@ class BaseCollector(ABC):
result = await db.execute(
select(DataSnapshot)
.where(DataSnapshot.source == self.name, DataSnapshot.is_current == True)
.where(DataSnapshot.source == self.name, DataSnapshot.is_current.is_(True))
.order_by(DataSnapshot.completed_at.desc().nullslast(), DataSnapshot.id.desc())
.limit(1)
)
@@ -324,6 +374,11 @@ class BaseCollector(ABC):
task.completed_at = datetime.now(UTC)
await db.commit()
await self._publish_task_update(force=True)
await self._publish_earth_update(
action="collector_completed",
records_processed=records_count,
task_id=task_id,
)
return {
"status": "success",
@@ -405,7 +460,7 @@ class BaseCollector(ABC):
select(CollectedData)
.where(
CollectedData.source == self.name,
CollectedData.is_current == True,
CollectedData.is_current.is_(True),
)
.order_by(CollectedData.entity_key.asc(), CollectedData.collected_at.desc().nullslast(), CollectedData.id.desc())
)
@@ -531,6 +586,7 @@ class BaseCollector(ABC):
}
await db.commit()
invalidate_earth_layer_cache_for_source(self.name)
await self.update_progress(len(data), force=True)
return records_added

View File

@@ -19,6 +19,7 @@ from app.services.bgp_collector_locations import (
)
from app.services.bgp_event_locations import resolve_bgp_event_geo_dict
from app.services.bgp_incidents import create_bgp_incidents_for_anomalies
from app.services.earth_layer_cache import invalidate_earth_layer_cache_for_source
from app.services.bgp_detectors import (
detect_mass_withdrawal_anomalies,
detect_more_specific_burst_anomalies,
@@ -223,6 +224,8 @@ async def save_bgp_observations_for_batch(
if created:
await db.commit()
for source in {"ris_live_bgp", "bgpstream_bgp"}:
invalidate_earth_layer_cache_for_source(source)
return created

View File

@@ -3,9 +3,11 @@
from __future__ import annotations
from dataclasses import dataclass
from copy import deepcopy
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm.attributes import flag_modified
from app.ai_tasks.prompts import get_effective_prompt
from app.models.system_setting import SystemSetting
@@ -135,33 +137,57 @@ DEFAULT_CREDENTIAL_GUIDES = {
}
def _normalize_provider(provider: str) -> str:
return provider.strip().lower().replace(" ", "_")
def _credential_guide_default(provider: str) -> CredentialGuideDefault:
normalized = _normalize_provider(provider)
known = DEFAULT_CREDENTIAL_GUIDES.get(normalized)
if known is not None:
return known
title = f"{normalized or 'collector'} 凭证配置教程"
return CredentialGuideDefault(
provider=normalized,
title=title,
prompt=(
f"请生成一份中文教程,指导开发者为 Planet 采集器配置 {normalized} 凭证。"
"教程要面向已经有本地开发环境的人,包含官方入口或文档查找方式、"
"获取 API Key / Token / Client credentials 的通用步骤、在 Planet 采集器配置中"
"填写凭证字段、连接测试、保存、常见失败排查。不要编造具体页面按钮文案;"
"如果公开资料不足,必须明确提醒以 provider 官方文档和当前控制台页面为准。"
),
markdown="",
)
async def _get_guide_store(db) -> tuple[SystemSetting | None, dict[str, Any]]:
result = await db.execute(
select(SystemSetting).where(SystemSetting.category == CREDENTIAL_GUIDES_CATEGORY)
)
record = result.scalar_one_or_none()
payload = dict(record.payload or {}) if record and isinstance(record.payload, dict) else {}
payload = deepcopy(record.payload) if record and isinstance(record.payload, dict) else {}
return record, payload
async def get_credential_guide(db, provider: str) -> dict[str, Any]:
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
if default is None:
raise ValueError(f"Unsupported credential guide provider: {provider}")
provider = _normalize_provider(provider)
default = _credential_guide_default(provider)
_record, store = await _get_guide_store(db)
custom = store.get(provider) if isinstance(store.get(provider), dict) else None
has_default_markdown = bool(default.markdown.strip())
return {
"provider": provider,
"title": custom.get("title") if custom else default.title,
"markdown": custom.get("markdown") if custom else default.markdown,
"prompt": default.prompt,
"source": "ai" if custom else "default",
"source": "ai" if custom else "default" if has_default_markdown else "missing",
"sources": custom.get("sources", []) if custom else [],
"verification_status": (
custom.get("verification_status", "verified_with_search_evidence")
if custom
else "default_unverified"
else "default_unverified" if has_default_markdown else "missing"
),
"verification_error": custom.get("verification_error") if custom else None,
}
@@ -177,9 +203,8 @@ async def save_credential_guide(
verification_status: str = "verified_with_search_evidence",
verification_error: str | None = None,
) -> dict[str, Any]:
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
if default is None:
raise ValueError(f"Unsupported credential guide provider: {provider}")
provider = _normalize_provider(provider)
default = _credential_guide_default(provider)
record, store = await _get_guide_store(db)
store[provider] = {
@@ -192,21 +217,21 @@ async def save_credential_guide(
if record is None:
db.add(SystemSetting(category=CREDENTIAL_GUIDES_CATEGORY, payload=store))
else:
record.payload = store
record.payload = deepcopy(store)
flag_modified(record, "payload")
await db.commit()
return await get_credential_guide(db, provider)
async def reset_credential_guide(db, provider: str) -> dict[str, Any]:
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
if default is None:
raise ValueError(f"Unsupported credential guide provider: {provider}")
provider = _normalize_provider(provider)
record, store = await _get_guide_store(db)
if provider in store:
store.pop(provider, None)
if record is not None:
record.payload = store
record.payload = deepcopy(store)
flag_modified(record, "payload")
await db.commit()
return await get_credential_guide(db, provider)
@@ -217,9 +242,8 @@ async def generate_credential_guide(
ai_client: AIProviderClient,
web_search_client: WebSearchClient | None = None,
) -> dict[str, Any]:
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
if default is None:
raise ValueError(f"Unsupported credential guide provider: {provider}")
provider = _normalize_provider(provider)
default = _credential_guide_default(provider)
search_evidence: list[dict[str, Any]] = []
search_error: str | None = None

View File

@@ -18,6 +18,7 @@ from app.models.datasource import DataSource
from app.models.datasource_config import DataSourceConfig
from app.models.system_setting import SystemSetting
from app.services.barentswatch import (
BarentsWatchConfig,
_read_zshrc_env,
fetch_barentswatch_access_token,
resolve_barentswatch_config,
@@ -44,6 +45,20 @@ def _resolve_spacetrack_credentials() -> tuple[str, str, str]:
return username, password, source or "missing"
def _resolve_spacetrack_credentials_with_override(
credential_override: dict[str, str] | None = None,
) -> tuple[str, str, str]:
if credential_override and (
credential_override.get("username") or credential_override.get("password")
):
return (
str(credential_override.get("username") or ""),
str(credential_override.get("password") or ""),
"draft",
)
return _resolve_spacetrack_credentials()
async def _resolve_aisstream_api_key(
db=None,
credential_override: dict[str, str] | None = None,
@@ -126,7 +141,9 @@ async def build_builtin_connectivity_checksum(
}
)
elif credential_provider == "spacetrack":
username, password, credential_source = _resolve_spacetrack_credentials()
username, password, credential_source = _resolve_spacetrack_credentials_with_override(
credential_override
)
has_credentials = bool(username and password)
credential_fingerprint = _sha256_json(
{
@@ -230,7 +247,16 @@ async def test_builtin_connectivity(
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
if credential_context["credential_provider"] == "barentswatch":
barentswatch_config = await resolve_barentswatch_config(db)
if credential_override:
barentswatch_config = BarentsWatchConfig(
endpoint=str(request_endpoint or ""),
client_id=str(credential_override.get("client_id") or ""),
client_secret=str(credential_override.get("client_secret") or ""),
credential_source="draft",
endpoint_source="draft",
)
else:
barentswatch_config = await resolve_barentswatch_config(db)
token = await fetch_barentswatch_access_token(client, barentswatch_config)
if not token:
return {
@@ -243,7 +269,9 @@ async def test_builtin_connectivity(
}
request_headers["Authorization"] = f"Bearer {token}"
elif credential_context["credential_provider"] == "spacetrack":
username, password, _source = _resolve_spacetrack_credentials()
username, password, _source = _resolve_spacetrack_credentials_with_override(
credential_override
)
login_url = "https://www.space-track.org/ajaxauth/login"
login_response = await client.post(
login_url,

View File

@@ -47,6 +47,8 @@ DOCS_METADATA: tuple[DocsMetadata, ...] = (
DocsMetadata("frontend-admin-frontend-context.md", "frontend-admin-frontend-context", "docs_developer", "Frontend", 20, "控制台前端结构", "Admin Frontend Context"),
DocsMetadata("frontend-layout-guidelines.md", "frontend-layout-guidelines", "docs_developer", "Frontend", 21, "前端布局指南", "Frontend Layout Guidelines"),
DocsMetadata("docs-gatekeeper-development.md", "docs-gatekeeper-development", "docs_developer", "Frontend", 22, "Docs Gatekeeper 开发说明", "Docs Gatekeeper Development Guide"),
DocsMetadata("naming-glossary.md", "naming-glossary", "docs_developer", "Frontend", 23, "命名与术语对照", "Naming Glossary"),
DocsMetadata("tactile-ui-components.md", "tactile-ui-components", "docs_developer", "Frontend", 24, "Tactile UI 组件库", "Tactile UI Components"),
DocsMetadata("backend-collectors.md", "backend-collectors", "docs_developer", "Backend", 30, "数据采集系统", "Data Collectors"),
DocsMetadata("backend-system-service-control.md", "backend-system-service-control", "docs_admin", "Backend", 31, "系统服务控制", "System Service Control"),
DocsMetadata("datasource-collector-settings-connectivity.md", "datasource-collector-settings-connectivity", "docs_developer", "Backend", 32, "数据源、采集器设置与连接验证", "Datasource Collector Settings and Connectivity"),

View File

@@ -0,0 +1,408 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import hashlib
import json
import time
from typing import Any
from fastapi import Response
from app.core.cache import _RedisClient
from app.core.config import settings
from app.core.logging import get_logger
logger = get_logger(__name__, service="earth_layer_cache")
EARTH_LAYER_CACHE_PREFIX = "earth:layer:v1"
EARTH_LAYER_LOCK_PREFIX = "earth:layer:lock:v1"
DEFAULT_LOCK_TTL_SECONDS = 10
DEFAULT_LOCK_WAIT_SECONDS = 0.2
DEFAULT_MAX_FEATURES = 5000
DEFAULT_MAX_BYTES = 5 * 1024 * 1024
DEFAULT_BBOX_PRECISION_DEGREES = 0.1
DEV_CACHE_KEY_HEADER = {"development", "dev", "test", "testing", "local"}
@dataclass(frozen=True)
class EarthLayerCachePolicy:
fresh_ttl_seconds: int
stale_ttl_seconds: int
max_features: int = DEFAULT_MAX_FEATURES
max_bytes: int = DEFAULT_MAX_BYTES
lock_ttl_seconds: int = DEFAULT_LOCK_TTL_SECONDS
lock_wait_seconds: float = DEFAULT_LOCK_WAIT_SECONDS
@dataclass(frozen=True)
class EarthLayerCacheResult:
payload: dict[str, Any]
state: str
key: str
features: int
bytes: int
class EarthLayerCache:
def __init__(self) -> None:
self._client = None
@property
def client(self):
if self._client is None:
self._client = _RedisClient.get_client()
return self._client
@staticmethod
def key(layer: str, **params: Any) -> str:
parts = [EARTH_LAYER_CACHE_PREFIX, _safe_key_part(layer)]
for name in sorted(params):
value = params[name]
if value is None:
value = "none"
parts.append(f"{_safe_key_part(name)}:{_safe_key_part(value)}")
return ":".join(parts)
@staticmethod
def stale_key(key: str) -> str:
return f"{key}:stale"
@staticmethod
def lock_key(key: str) -> str:
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:32]
return f"{EARTH_LAYER_LOCK_PREFIX}:{digest}"
def get_json(self, key: str) -> dict[str, Any] | None:
raw = self.client.get(key)
if not raw:
return None
value = json.loads(raw)
return value if isinstance(value, dict) else None
def set_json(self, key: str, payload: dict[str, Any], ttl_seconds: int) -> None:
self.client.setex(key, ttl_seconds, json.dumps(payload, ensure_ascii=False, default=str))
def acquire_lock(self, key: str, ttl_seconds: int) -> bool:
return bool(self.client.set(self.lock_key(key), "1", nx=True, ex=ttl_seconds))
def release_lock(self, key: str) -> None:
try:
self.client.delete(self.lock_key(key))
except Exception:
pass
def delete_pattern(self, pattern: str = f"{EARTH_LAYER_CACHE_PREFIX}:*") -> int:
keys = list(self.client.scan_iter(match=pattern))
if not keys:
return 0
return int(self.client.delete(*keys))
def status(self) -> dict[str, Any]:
keys = list(self.client.scan_iter(match=f"{EARTH_LAYER_CACHE_PREFIX}:*"))
by_layer: dict[str, dict[str, Any]] = {}
total_memory = 0
for key in keys:
key_str = key.decode("utf-8") if isinstance(key, bytes) else str(key)
layer = _layer_from_key(key_str)
entry = by_layer.setdefault(layer, {"keys": 0, "stale_keys": 0, "memory_bytes": 0})
entry["keys"] += 1
if key_str.endswith(":stale"):
entry["stale_keys"] += 1
try:
memory = int(self.client.memory_usage(key) or 0)
except Exception:
memory = 0
entry["memory_bytes"] += memory
total_memory += memory
return {
"prefix": EARTH_LAYER_CACHE_PREFIX,
"key_count": len(keys),
"memory_bytes": total_memory,
"layers": by_layer,
}
earth_layer_cache = EarthLayerCache()
def quantize_bbox(
bbox: tuple[float, float, float, float],
*,
precision: float = DEFAULT_BBOX_PRECISION_DEGREES,
) -> tuple[float, float, float, float]:
return tuple(round(value / precision) * precision for value in bbox) # type: ignore[return-value]
def format_bbox_key(bbox: tuple[float, float, float, float]) -> str:
return ",".join(f"{value:.1f}" for value in bbox)
def apply_cache_headers(response: Response | None, result: EarthLayerCacheResult) -> None:
if response is None:
return
response.headers["X-Planet-Cache"] = result.state
response.headers["X-Planet-Cache-Features"] = str(result.features)
response.headers["X-Planet-Cache-Bytes"] = str(result.bytes)
env_name = str(getattr(settings, "ENVIRONMENT", "") or "development").lower()
if env_name in DEV_CACHE_KEY_HEADER:
response.headers["X-Planet-Cache-Key"] = result.key
async def get_or_build_layer_payload(
*,
key: str,
policy: EarthLayerCachePolicy,
builder: Callable[[], Awaitable[dict[str, Any]]],
response: Response | None = None,
) -> dict[str, Any]:
result = await resolve_layer_payload(key=key, policy=policy, builder=builder)
apply_cache_headers(response, result)
return result.payload
async def resolve_layer_payload(
*,
key: str,
policy: EarthLayerCachePolicy,
builder: Callable[[], Awaitable[dict[str, Any]]],
) -> EarthLayerCacheResult:
started = time.perf_counter()
try:
cached = earth_layer_cache.get_json(key)
if cached is not None:
return _result(cached, state="hit", key=key)
lock_acquired = earth_layer_cache.acquire_lock(key, policy.lock_ttl_seconds)
if lock_acquired:
try:
payload = await _build_budgeted_payload(builder, policy)
_write_fresh_and_stale(key, payload, policy)
_log_cache_event("refresh", key, payload, started)
return _result(payload, state="refresh", key=key)
except Exception as exc:
stale = _read_stale(key)
if stale is not None:
logger.warning_event(
"Earth layer cache builder failed; returning stale payload",
event="earth_layer_cache.stale_after_builder_error",
context={"key": key, "error": str(exc)},
)
return _result(stale, state="stale", key=key)
raise
finally:
earth_layer_cache.release_lock(key)
stale = _read_stale(key)
if stale is not None:
return _result(stale, state="stale", key=key)
await asyncio.sleep(policy.lock_wait_seconds)
cached_after_wait = earth_layer_cache.get_json(key)
if cached_after_wait is not None:
return _result(cached_after_wait, state="hit", key=key)
payload = await _build_budgeted_payload(builder, policy)
_log_cache_event("miss", key, payload, started)
return _result(payload, state="miss", key=key)
except Exception as exc:
try:
payload = await _build_budgeted_payload(builder, policy)
except Exception:
raise exc
logger.warning_event(
"Earth layer cache bypassed",
event="earth_layer_cache.bypass",
context={"key": key, "error": str(exc)},
)
return _result(payload, state="bypass", key=key)
def apply_payload_budget(payload: dict[str, Any], policy: EarthLayerCachePolicy) -> dict[str, Any]:
budgeted = _truncate_features(payload, policy.max_features, "feature_budget")
size = _payload_size(budgeted)
if size <= policy.max_bytes:
return budgeted
features = budgeted.get("features")
if not isinstance(features, list):
return _with_budget_diagnostics(
budgeted,
truncated=True,
reason="byte_budget",
bytes_before=size,
bytes_after=size,
)
low = 0
high = len(features)
best = []
best_size = _payload_size({**budgeted, "features": best})
while low <= high:
mid = (low + high) // 2
candidate_features = features[:mid]
candidate = _with_budget_diagnostics(
{**budgeted, "features": candidate_features},
truncated=mid < len(features),
reason="byte_budget",
bytes_before=size,
bytes_after=0,
)
candidate_size = _payload_size(candidate)
if candidate_size <= policy.max_bytes:
best = candidate_features
best_size = candidate_size
low = mid + 1
else:
high = mid - 1
return _with_budget_diagnostics(
{**budgeted, "features": best},
truncated=True,
reason="byte_budget",
bytes_before=size,
bytes_after=best_size,
)
def invalidate_earth_layer_cache_for_source(source: str) -> int:
source_key = str(source or "").strip()
patterns = {
"barentswatch_vessels": ["vessels*", "summary*"],
"aisstream_vessels": ["vessels*", "summary*"],
"telegeography_cables": ["cables*", "landing-points*", "summary*"],
"telegeography_landing": ["landing-points*", "summary*"],
"telegeography_landing_points": ["landing-points*", "summary*"],
"telegeography_systems": ["cables*", "summary*"],
"telegeography_cable_systems": ["cables*", "summary*"],
"arcgis_cables": ["cables*", "landing-points*", "summary*"],
"arcgis_landing_points": ["landing-points*", "summary*"],
"arcgis_cable_landing_relation": ["landing-points*", "summary*"],
"arcgis_cable_landing_relations": ["landing-points*", "summary*"],
"fao_landing_points": ["landing-points*", "summary*"],
"celestrak_tle": ["satellites*", "summary*"],
"spacetrack_tle": ["satellites*", "summary*"],
"top500": ["compute-centers*", "summary*"],
"top500_supercomputers": ["compute-centers*", "summary*"],
"epoch_ai_gpu": ["compute-centers*", "summary*"],
"huggingface_models": ["compute-centers*", "summary*"],
"huggingface_datasets": ["compute-centers*", "summary*"],
"huggingface_spaces": ["compute-centers*", "summary*"],
"ris_live_bgp": ["bgp*", "summary*"],
"bgpstream_bgp": ["bgp*", "summary*"],
"iptoasn_prefix_geo": ["bgp*", "summary*"],
"opengeofeed_prefix_geo": ["bgp*", "summary*"],
"nro_delegated_prefix_geo": ["bgp*", "summary*"],
}.get(source_key, [])
deleted = 0
for layer_pattern in patterns:
deleted += earth_layer_cache.delete_pattern(f"{EARTH_LAYER_CACHE_PREFIX}:{layer_pattern}")
return deleted
async def _build_budgeted_payload(
builder: Callable[[], Awaitable[dict[str, Any]]],
policy: EarthLayerCachePolicy,
) -> dict[str, Any]:
payload = await builder()
return apply_payload_budget(payload, policy)
def _write_fresh_and_stale(key: str, payload: dict[str, Any], policy: EarthLayerCachePolicy) -> None:
earth_layer_cache.set_json(key, payload, policy.fresh_ttl_seconds)
earth_layer_cache.set_json(earth_layer_cache.stale_key(key), payload, policy.stale_ttl_seconds)
def _read_stale(key: str) -> dict[str, Any] | None:
try:
return earth_layer_cache.get_json(earth_layer_cache.stale_key(key))
except Exception:
return None
def _truncate_features(payload: dict[str, Any], max_features: int, reason: str) -> dict[str, Any]:
features = payload.get("features")
if not isinstance(features, list) or len(features) <= max_features:
return payload
return _with_budget_diagnostics(
{**payload, "features": features[:max_features]},
truncated=True,
reason=reason,
original_feature_count=len(features),
)
def _with_budget_diagnostics(
payload: dict[str, Any],
*,
truncated: bool,
reason: str,
original_feature_count: int | None = None,
bytes_before: int | None = None,
bytes_after: int | None = None,
) -> dict[str, Any]:
diagnostics = dict(payload.get("diagnostics") or {})
diagnostics.update(
{
"truncated": bool(truncated or diagnostics.get("truncated")),
"limit_reason": reason,
}
)
if original_feature_count is not None:
diagnostics["original_feature_count"] = original_feature_count
if bytes_before is not None:
diagnostics["bytes_before_budget"] = bytes_before
if bytes_after is not None:
diagnostics["bytes_after_budget"] = bytes_after
return {**payload, "diagnostics": diagnostics}
def _result(payload: dict[str, Any], *, state: str, key: str) -> EarthLayerCacheResult:
return EarthLayerCacheResult(
payload=payload,
state=state,
key=key,
features=_feature_count(payload),
bytes=_payload_size(payload),
)
def _feature_count(payload: dict[str, Any]) -> int:
features = payload.get("features")
if isinstance(features, list):
return len(features)
count = payload.get("count")
return int(count) if isinstance(count, int) else 0
def _payload_size(payload: dict[str, Any]) -> int:
return len(json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8"))
def _safe_key_part(value: Any) -> str:
raw = str(value).strip().lower()
return "".join(char if char.isalnum() or char in {"-", "_", ".", ","} else "_" for char in raw)[:160]
def _layer_from_key(key: str) -> str:
prefix = f"{EARTH_LAYER_CACHE_PREFIX}:"
if not key.startswith(prefix):
return "unknown"
remainder = key[len(prefix):]
return remainder.split(":", 1)[0]
def _log_cache_event(state: str, key: str, payload: dict[str, Any], started: float) -> None:
logger.info_event(
"Earth layer cache resolved",
event="earth_layer_cache.resolved",
context={
"state": state,
"key": key,
"features": _feature_count(payload),
"bytes": _payload_size(payload),
"duration_ms": round((time.perf_counter() - started) * 1000, 2),
},
)

View File

@@ -661,7 +661,7 @@ async def _infer_news_enrichment(
"localizations": {
"zh-CN": {
"title": "faithful Simplified Chinese title",
"summary": "1-2 sentence faithful Simplified Chinese summary",
"summary": "one-sentence newswire-style Simplified Chinese lead summary",
}
},
},
@@ -669,8 +669,9 @@ async def _infer_news_enrichment(
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.",
"If the RSS description is thin, write a conservative summary that says only what is supported.",
"Keep zh-CN summary concise, factual, and non-promotional.",
"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.",
@@ -852,7 +853,7 @@ def _parse_feed_entries(xml_text: str, source: NewsFeedSource) -> list[ParsedNew
if not clean_title or not link:
continue
item_source = _normalize_source_name(clean_title, source.name)
item_source = source.name
display_title = clean_title
if source.source_type == "aggregated" and " - " in clean_title:
parts = clean_title.rsplit(" - ", 1)

View File

@@ -7,6 +7,26 @@ from typing import Any
import httpx
MODELS_DEV_URL = "https://models.dev/api.json"
OPENCODE_GO_MODELS_URL = "https://opencode.ai/zen/go/v1/models"
OPENCODE_GO_MODEL_PROVIDER_APIS = {
"minimax-m2.7": "anthropic-messages",
"minimax-m2.5": "anthropic-messages",
}
OPENCODE_GO_FALLBACK_MODELS = [
"minimax-m2.7",
"minimax-m2.5",
"kimi-k2.6",
"kimi-k2.5",
"glm-5.1",
"glm-5",
"deepseek-v4-pro",
"deepseek-v4-flash",
"qwen3.6-plus",
"qwen3.5-plus",
"mimo-v2.5-pro",
"mimo-v2.5",
]
FALLBACK_LLM_PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
@@ -80,6 +100,17 @@ FALLBACK_LLM_PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
"api_key_env": "OPENROUTER_API_KEY",
"source": "fallback",
},
"opencode-go": {
"provider": "opencode-go",
"label": "OpenCode Go",
"provider_api": "openai-completions",
"base_url": "https://opencode.ai/zen/go/v1",
"model": "glm-5.1",
"models": OPENCODE_GO_FALLBACK_MODELS,
"model_provider_apis": OPENCODE_GO_MODEL_PROVIDER_APIS,
"api_key_env": "OPENCODE_GO_API_KEY",
"source": "fallback",
},
"ollama": {
"provider": "ollama",
"label": "Ollama Local",
@@ -114,8 +145,43 @@ def get_fallback_llm_provider_preset(provider: str) -> dict[str, Any]:
return dict(FALLBACK_LLM_PROVIDER_PRESETS[key])
async def refresh_llm_provider_preset(provider: str) -> dict[str, Any]:
def _opencode_go_model_provider_apis(model_ids: list[str]) -> dict[str, str]:
return {
model_id: OPENCODE_GO_MODEL_PROVIDER_APIS.get(model_id, "openai-completions")
for model_id in model_ids
}
async def refresh_llm_provider_preset(provider: str, api_key: str | None = None) -> dict[str, Any]:
fallback = get_fallback_llm_provider_preset(provider)
if fallback["provider"] == "opencode-go":
headers = {"User-Agent": "Planet/1.0"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
response = await client.get(
OPENCODE_GO_MODELS_URL,
headers=headers,
)
response.raise_for_status()
payload = response.json()
data = payload.get("data") if isinstance(payload, dict) else []
model_ids = [
str(item.get("id"))
for item in data
if isinstance(item, dict) and item.get("id")
][:120]
if not model_ids:
model_ids = fallback["models"]
return {
**fallback,
"model": fallback["model"] if fallback["model"] in model_ids else model_ids[0],
"models": model_ids,
"model_provider_apis": _opencode_go_model_provider_apis(model_ids),
"source": OPENCODE_GO_MODELS_URL,
}
models_dev_key = MODELS_DEV_PROVIDER_KEYS.get(fallback["provider"])
if not models_dev_key:
return fallback

View File

@@ -9,6 +9,7 @@ from typing import Any, Iterable
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.core.countries import COUNTRY_ENTRIES, normalize_country
from app.schemas.ai import SituationalAnalysisRequest
from app.ai_tasks.prompts import get_effective_prompt
@@ -29,6 +30,9 @@ DEFAULT_MIN_CONFIDENCE = 0.55
LOCATION_NORMALIZE_PROMPT_KEY = "location.factcheck.normalize"
LOCATION_RESOLVE_PROMPT_KEY = "location.factcheck.resolve"
MODEL_CONFIDENCE_WEIGHT = 0.25
LOG_TEXT_LIMIT = 1200
LOG_EVIDENCE_LIMIT = 5
logger = get_logger(__name__, service="location")
_geocode_llm_city = build_default_nominatim_geocoder()
_LLM_LOCATION_NAME_KEYS = (
"matched_location_name",
@@ -97,6 +101,35 @@ class LocationEvidenceScore:
summary: str
def _truncate_log_text(value: Any, limit: int = LOG_TEXT_LIMIT) -> str:
text = coerce_str(value)
if len(text) <= limit:
return text
return f"{text[:limit]}"
def _summarize_search_evidence(evidence: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for item in (evidence or [])[:LOG_EVIDENCE_LIMIT]:
if not isinstance(item, dict):
continue
items.append(
{
"title": _truncate_log_text(item.get("title"), 180),
"source": _truncate_log_text(item.get("source") or item.get("name"), 120),
"url": _truncate_log_text(item.get("url"), 240),
"snippet": _truncate_log_text(
item.get("snippet")
or item.get("content")
or item.get("text")
or item.get("summary"),
360,
),
}
)
return items
def _first_json_object(text: str) -> dict[str, Any] | None:
stripped = text.strip()
if not stripped:
@@ -162,6 +195,64 @@ def _evidence_label(item: Any) -> str:
return coerce_str(item)
def _evidence_text(item: dict[str, Any]) -> str:
return " ".join(
coerce_str(item.get(key))
for key in ("title", "source", "name", "url", "snippet", "content", "text", "quote", "summary")
if coerce_str(item.get(key))
)
def _search_evidence_entity_match(item: dict[str, Any], query: LocationQuery) -> bool:
haystack = normalize_text(_evidence_text(item))
if not haystack:
return False
needles = [
coerce_str(query.name),
*[coerce_str(alias) for alias in query.aliases],
]
return any(normalize_text(needle) and normalize_text(needle) in haystack for needle in needles)
def _evidence_has_location_assertion(item: dict[str, Any], city: str) -> bool:
normalized_city = normalize_text(city)
text = normalize_text(_evidence_text(item))
if not normalized_city or normalized_city not in text:
return False
assertion_terms = (
"located",
"situated",
"built",
"hosted",
"deployed",
"installed",
"facility",
"campus",
"site",
"data center",
"datacenter",
"supercomputer center",
"位于",
"位於",
"坐落",
"建置",
"設置",
"设置",
)
return any(term in text for term in assertion_terms)
def _city_is_unsupported_name_hint(payload: dict[str, Any], query: LocationQuery, evidence_items: list[dict[str, Any]]) -> bool:
city = coerce_str(payload.get("city") or query.city)
if not city:
return False
normalized_city = normalize_text(city)
normalized_name = normalize_text(query.name)
if not normalized_city or not normalized_name or normalized_city not in normalized_name:
return False
return not any(_evidence_has_location_assertion(item, city) for item in evidence_items)
def _normalize_llm_precision(value: Any) -> str:
text = coerce_str(value).lower()
return LLM_PRECISION_ALIASES.get(text, text)
@@ -588,6 +679,7 @@ def _weak_evidence_penalty(
payload: dict[str, Any],
evidence_items: list[dict[str, Any]],
*,
query: LocationQuery,
entity_match: float,
geography_match: float,
conflict_penalty: float,
@@ -598,6 +690,8 @@ def _weak_evidence_penalty(
penalty += 0.20
if any(_truthy_evidence_field(item, "ambiguous") for item in evidence_items):
penalty += 0.15
if _city_is_unsupported_name_hint(payload, query, evidence_items):
penalty += 0.10
if conflict_penalty == 0.0 and entity_match > 0 and geography_match >= 0.20:
return min(penalty, 0.15)
return min(penalty, 0.30)
@@ -620,6 +714,7 @@ def _score_llm_location_payload(
weak_evidence_penalty = _weak_evidence_penalty(
payload,
evidence_items,
query=query,
entity_match=entity_match,
geography_match=geography_match,
conflict_penalty=conflict_penalty,
@@ -636,6 +731,8 @@ def _score_llm_location_payload(
- weak_evidence_penalty
)
score = min(max(score, 0.0), 1.0)
if _city_is_unsupported_name_hint(payload, query, evidence_items):
score = min(score, 0.54)
summary = (
f"combined={score:.2f}; model={model_confidence:.2f}; "
f"source={source_quality:.2f}; entity={entity_match:.2f}; "
@@ -847,15 +944,43 @@ async def collect_location_search_evidence(
) -> LocationSearchEvidenceResult:
search_query = _location_search_query(query, entity_type)
attempt = f"web_search:{entity_type}:{search_query}"
logger.info_event(
"Collecting location search evidence",
event="location.factcheck.web_search.start",
context={
"entity_type": entity_type,
"search_query": search_query,
"location_query": _query_context(query),
"max_results": max_results,
},
)
try:
evidence = await web_search_client.search(search_query, max_results=max_results)
except WebSearchError as exc:
logger.warning_event(
"Location search evidence failed",
event="location.factcheck.web_search.failed",
context={
"entity_type": entity_type,
"search_query": search_query,
"error": str(exc),
},
)
return LocationSearchEvidenceResult(
evidence=[],
attempted_queries=[attempt],
failure_reason=f"WebSearch location evidence failed: {exc}",
)
except Exception as exc:
logger.warning_event(
"Location search evidence unavailable",
event="location.factcheck.web_search.unavailable",
context={
"entity_type": entity_type,
"search_query": search_query,
"error": str(exc),
},
)
return LocationSearchEvidenceResult(
evidence=[],
attempted_queries=[attempt],
@@ -863,11 +988,29 @@ async def collect_location_search_evidence(
)
normalized = normalize_search_evidence(evidence, limit=max_results)
if not normalized:
logger.warning_event(
"Location search returned no usable evidence",
event="location.factcheck.web_search.empty",
context={
"entity_type": entity_type,
"search_query": search_query,
},
)
return LocationSearchEvidenceResult(
evidence=[],
attempted_queries=[attempt],
failure_reason="WebSearch returned no usable location evidence.",
)
logger.info_event(
"Collected location search evidence",
event="location.factcheck.web_search.result",
context={
"entity_type": entity_type,
"search_query": search_query,
"evidence_count": len(normalized),
"evidence": _summarize_search_evidence(normalized),
},
)
return LocationSearchEvidenceResult(
evidence=normalized,
attempted_queries=[attempt],
@@ -947,6 +1090,15 @@ async def collect_llm_location_fallback_candidate(
"""
attempt = f"llm_factcheck:{entity_type}:{coerce_str(query.name) or 'unknown'}"
if search_evidence is not None and not search_evidence:
logger.warning_event(
"Skipping LLM location factcheck because search evidence is empty",
event="location.factcheck.llm.skipped_no_evidence",
context={
"entity_type": entity_type,
"attempt": attempt,
"location_query": _query_context(query),
},
)
return LocationLLMFallbackResult(
candidates=[],
attempted_queries=[attempt],
@@ -986,20 +1138,66 @@ async def collect_llm_location_fallback_candidate(
"Return evidence as objects when possible, including source, url, source_type, and entity_match.",
"Include source names or URLs in evidence when known. The backend will recompute the final confidence from model confidence plus evidence quality.",
"If search_evidence is provided, use only that evidence as factual support.",
"Do not treat a website footer, office address, publisher address, or contact address as the entity's physical location.",
"If the entity name contains a city name, do not choose that city unless evidence explicitly says the entity/facility/supercomputer is located, hosted, built, deployed, or installed there.",
"Prefer the facility/site if known; otherwise use the best supported city.",
],
)
logger.info_event(
"Sending location factcheck request to LLM",
event="location.factcheck.llm.request",
context={
"entity_type": entity_type,
"attempt": attempt,
"title": request.title,
"objective": request.objective,
"location_query": request.context.get("location_query"),
"observations": request.observations,
"constraints": request.constraints,
"search_evidence_count": len(search_evidence or []),
"search_evidence": _summarize_search_evidence(search_evidence),
},
)
try:
response = await provider_client.analyze(request)
except Exception as exc:
logger.warning_event(
"LLM location factcheck failed",
event="location.factcheck.llm.failed",
context={
"entity_type": entity_type,
"attempt": attempt,
"error": str(exc),
},
)
return LocationLLMFallbackResult(
candidates=[],
attempted_queries=[attempt],
failure_reason=f"LLM location factcheck failed: {exc}",
)
logger.info_event(
"Received location factcheck response from LLM",
event="location.factcheck.llm.response",
context={
"entity_type": entity_type,
"attempt": attempt,
"provider": response.provider,
"model": response.model,
"content": _truncate_log_text(response.content, 2000),
},
)
payload = _first_json_object(response.content)
if payload is None:
logger.warning_event(
"LLM location factcheck response was not strict JSON; attempting repair",
event="location.factcheck.llm.non_json",
context={
"entity_type": entity_type,
"attempt": attempt,
"content": _truncate_log_text(response.content, 1200),
},
)
payload = await _repair_location_payload_from_text(
provider_client=provider_client,
raw_text=response.content,
@@ -1009,9 +1207,17 @@ async def collect_llm_location_fallback_candidate(
)
if payload is None:
payload = _payload_from_free_text(response.content, query=query)
if payload is None:
if payload is None and entity_type != "compute_center":
payload = _payload_from_query_name_geocode(query)
if payload is None:
logger.warning_event(
"LLM location factcheck produced no parseable payload",
event="location.factcheck.llm.unparseable",
context={
"entity_type": entity_type,
"attempt": attempt,
},
)
return LocationLLMFallbackResult(
candidates=[],
attempted_queries=[attempt],
@@ -1032,7 +1238,7 @@ async def collect_llm_location_fallback_candidate(
"url": item.get("url"),
"text": item.get("snippet") or item.get("content"),
"source_type": "web_search",
"entity_match": True,
"entity_match": _search_evidence_entity_match(item, query),
}
for item in search_evidence
if isinstance(item, dict)
@@ -1054,6 +1260,18 @@ async def collect_llm_location_fallback_candidate(
if candidate is None:
if city_geocode_failure and rejection_reason == "missing, invalid, or zero latitude/longitude":
rejection_reason = f"{rejection_reason}; {city_geocode_failure}"
logger.warning_event(
"Rejected LLM location factcheck candidate",
event="location.factcheck.llm.rejected",
context={
"entity_type": entity_type,
"attempt": attempt,
"reason": rejection_reason,
"payload": payload,
"search_evidence_count": len(search_evidence or []),
"search_evidence": _summarize_search_evidence(search_evidence),
},
)
return LocationLLMFallbackResult(
candidates=[],
attempted_queries=[attempt],
@@ -1062,6 +1280,18 @@ async def collect_llm_location_fallback_candidate(
+ (f": {rejection_reason}." if rejection_reason else ".")
),
)
logger.info_event(
"Accepted LLM location factcheck candidate",
event="location.factcheck.llm.accepted",
context={
"entity_type": entity_type,
"attempt": attempt,
"candidate": candidate.to_dict(),
"payload": payload,
"search_evidence_count": len(search_evidence or []),
"search_evidence": _summarize_search_evidence(search_evidence),
},
)
return LocationLLMFallbackResult(
candidates=[candidate],
attempted_queries=[attempt],

View File

@@ -99,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)}"
),
]
@@ -150,12 +156,12 @@ 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,

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
@@ -86,6 +87,7 @@ class LogSource:
status: str = "ok"
buffer_key: str | None = None
container_name: str | None = None
fallback_locations: tuple[str, ...] = ()
@dataclass
@@ -104,22 +106,38 @@ class DailyLogMarker:
dominant_level: str
def _planet_state_dir() -> Path:
configured = os.getenv("PLANET_STATE_DIR")
if configured:
return Path(configured).expanduser()
xdg_state = os.getenv("XDG_STATE_HOME")
if xdg_state:
return Path(xdg_state).expanduser() / "planet"
return Path.home() / ".local" / "state" / "planet"
def _state_log_path(filename: str) -> str:
return str(_planet_state_dir() / filename)
LOG_SOURCES: dict[str, LogSource] = {
"backend": LogSource(
source_id="backend",
name="后端服务",
kind="file",
location="/tmp/planet_backend.log",
location=_state_log_path("backend.log"),
description="FastAPI 后端、调度器和采集任务共享日志。",
category="service",
fallback_locations=("/tmp/planet_backend.log",),
),
"frontend": LogSource(
source_id="frontend",
name="前端开发服务",
kind="file",
location="/tmp/planet_frontend.log",
location=_state_log_path("frontend.log"),
description="控制台与 Earth 前端开发服务输出。",
category="service",
fallback_locations=("/tmp/planet_frontend.log",),
),
"ai-provider": LogSource(
source_id="ai-provider",
@@ -164,9 +182,18 @@ def normalize_log_levels(level: str | None = None, levels: str | None = None) ->
return tuple(normalized_levels)
def resolve_file_log_path(source: LogSource) -> Path:
primary = Path(source.location).expanduser()
candidates = (primary, *(Path(item).expanduser() for item in source.fallback_locations))
for candidate in candidates:
if candidate.exists():
return candidate
return primary
def get_source_status(source: LogSource) -> str:
if source.kind == "file":
path = Path(source.location)
path = resolve_file_log_path(source)
if not path.exists():
return "missing"
return "ok" if path.stat().st_size > 0 else "empty"
@@ -190,7 +217,7 @@ def list_log_sources() -> list[dict[str, str]]:
"source_id": source.source_id,
"name": source.name,
"kind": source.kind,
"location": source.location,
"location": str(resolve_file_log_path(source)) if source.kind == "file" else source.location,
"description": source.description,
"category": source.category,
"status": get_source_status(source),
@@ -339,7 +366,7 @@ def build_buffer_entry(payload: dict[str, Any]) -> StructuredLogEntry:
def read_file_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]:
path = Path(source.location)
path = resolve_file_log_path(source)
if not path.exists():
return []
with path.open("r", encoding="utf-8", errors="replace") as handle:
@@ -511,7 +538,7 @@ def read_log_snapshot(
"source_id": source.source_id,
"name": source.name,
"kind": source.kind,
"location": source.location,
"location": str(resolve_file_log_path(source)) if source.kind == "file" else source.location,
"description": source.description,
"category": source.category,
"status": get_source_status(source),

View File

@@ -385,13 +385,18 @@ def build_public_tv_payload(
settings_payload: dict[str, Any],
collected_sources: list[dict[str, Any]],
) -> dict[str, Any]:
configured_by_id = {
source["id"]: source
for source in settings_payload["sources"]
if source.get("id")
}
configured_sources = [
source for source in settings_payload["sources"] if source["is_enabled"]
]
merged_by_id = {source["id"]: source for source in configured_sources}
for source in collected_sources:
if source["id"] in merged_by_id or not source["is_enabled"]:
if source["id"] in configured_by_id or not source["is_enabled"]:
continue
merged_by_id[source["id"]] = source

View File

@@ -18,7 +18,7 @@ async def create_admin():
if existing_user:
print("用户 linkong 已存在,更新密码...")
existing_user.set_password("12345678")
existing_user.set_password("LK12345678")
existing_user.role = "super_admin"
existing_user.email = "linkong@planet.local"
else:
@@ -26,7 +26,7 @@ async def create_admin():
user = User(
username="linkong",
email="linkong@planet.local",
password_hash=get_password_hash("12345678"),
password_hash=get_password_hash("LK12345678"),
role="super_admin",
is_active=True,
)

View File

@@ -19,7 +19,7 @@ DEFAULT_LOGIN_USERS = (
{
"username": "linkong",
"email": "linkong@planet.local",
"password": "12345678",
"password": "LK12345678",
"role": "super_admin",
},
)

View File

@@ -22,7 +22,7 @@ DEFAULT_LOGIN_USERS = (
{
"username": "linkong",
"email": "linkong@planet.local",
"password": "12345678",
"password": "LK12345678",
"role": "super_admin",
},
)

View File

@@ -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"""
@@ -528,6 +672,77 @@ async def test_ingest_earth_client_log_accepts_public_events():
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_earth_layer_cache_status_requires_super_admin(auth_headers, monkeypatch):
def override_get_current_user():
return User(
id=1,
username="root",
email="root@example.com",
password_hash="hashed",
role="super_admin",
is_active=True,
)
monkeypatch.setattr(
"app.api.v1.system_control.earth_layer_cache.status",
lambda: {
"prefix": "earth:layer:v1",
"key_count": 2,
"memory_bytes": 42,
"layers": {"cables": {"keys": 2, "stale_keys": 1, "memory_bytes": 42}},
},
)
app.dependency_overrides = {
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
}
transport = ASGITransport(app=app)
try:
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/system/cache/earth-layers", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["prefix"] == "earth:layer:v1"
assert data["layers"]["cables"]["stale_keys"] == 1
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_clear_earth_layer_cache_deletes_only_earth_layer_prefix(auth_headers, monkeypatch):
def override_get_current_user():
return User(
id=1,
username="root",
email="root@example.com",
password_hash="hashed",
role="super_admin",
is_active=True,
)
captured = {}
def fake_delete_pattern(pattern="earth:layer:v1:*"):
captured["pattern"] = pattern
return 3
monkeypatch.setattr("app.api.v1.system_control.earth_layer_cache.delete_pattern", fake_delete_pattern)
app.dependency_overrides = {
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
}
transport = ASGITransport(app=app)
try:
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.delete("/api/v1/system/cache/earth-layers", headers=auth_headers)
assert response.status_code == 200
assert response.json()["deleted"] == 3
assert captured["pattern"] == "earth:layer:v1:*"
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_request_id_header_is_echoed_when_provided():
transport = ASGITransport(app=app)

View File

@@ -4,6 +4,7 @@ import pytest
from app.api.v1 import datasources as datasources_api
from app.models.datasource import DataSource
from app.services import earth_layer_cache as earth_cache
def make_datasource(
@@ -53,6 +54,71 @@ def test_filter_datasources_by_product_status_and_collected_state():
assert filtered == [vessels]
def test_serialize_datasource_row_can_skip_endpoint_resolution():
datasource = make_datasource(7, "arcgis_cables", last_status="success", module="L2")
class ExplodingConfig:
def get_yaml_url(self, _source):
raise AssertionError("endpoint resolution should be skipped")
row = datasources_api.serialize_datasource_row(
datasource,
running_tasks={},
latest_tasks={},
record_counts={"arcgis_cables": 343},
endpoint_overrides={},
config=ExplodingConfig(),
include_endpoint=False,
)
assert row["id"] == 7
assert row["source"] == "arcgis_cables"
assert row["module"] == "L2"
assert row["last_status"] == "success"
assert row["collected_records"] == 343
assert row["has_collected_data"] is True
assert "endpoint" not in row
def test_serialize_datasource_row_includes_endpoint_when_requested():
datasource = make_datasource(8, "arcgis_landing_points")
class Config:
def get_yaml_url(self, source):
return f"https://example.test/{source}"
row = datasources_api.serialize_datasource_row(
datasource,
running_tasks={},
latest_tasks={},
record_counts={},
endpoint_overrides={},
config=Config(),
include_endpoint=True,
)
assert row["endpoint"] == "https://example.test/arcgis_landing_points"
def test_invalidate_earth_layer_cache_for_source_covers_datasource_aliases(monkeypatch):
patterns: list[str] = []
def fake_delete_pattern(pattern: str) -> int:
patterns.append(pattern)
return 1
monkeypatch.setattr(earth_cache.earth_layer_cache, "delete_pattern", fake_delete_pattern)
deleted = earth_cache.invalidate_earth_layer_cache_for_source("telegeography_cables")
assert deleted == 3
assert patterns == [
"earth:layer:v1:cables*",
"earth:layer:v1:landing-points*",
"earth:layer:v1:summary*",
]
@pytest.mark.asyncio
async def test_trigger_datasource_batch_skips_disabled_and_frequency_window(monkeypatch):
now = datetime.now(timezone.utc)

View File

@@ -52,6 +52,19 @@ async def test_public_catalog_only_for_anonymous_user():
}
@pytest.mark.asyncio
async def test_developer_catalog_includes_frontend_reference_docs():
response = await get_json(
"/api/v1/docs/catalog",
make_user(role="viewer", groups=["docs_developer"]),
)
assert response.status_code == 200
zh_slugs = {item["slug"] for item in response.json()["items"] if item["lang"] == "zh"}
assert "naming-glossary" in zh_slugs
assert "tactile-ui-components" in zh_slugs
@pytest.mark.asyncio
async def test_anonymous_can_read_public_doc():
response = await get_json("/api/v1/docs/zh/quickstart")
@@ -83,10 +96,16 @@ async def test_developer_group_can_read_developer_but_not_admin_doc():
user = make_user(role="viewer", groups=["docs_developer"])
developer_response = await get_json("/api/v1/docs/zh/backend-collectors", user)
tactile_response = await get_json("/api/v1/docs/zh/tactile-ui-components", user)
glossary_response = await get_json("/api/v1/docs/zh/naming-glossary", user)
admin_response = await get_json("/api/v1/docs/zh/backend-system-service-control", user)
assert developer_response.status_code == 200
assert developer_response.json()["access"] == "docs_developer"
assert tactile_response.status_code == 200
assert tactile_response.json()["access"] == "docs_developer"
assert glossary_response.status_code == 200
assert glossary_response.json()["access"] == "docs_developer"
assert admin_response.status_code == 403

View File

@@ -0,0 +1,254 @@
from __future__ import annotations
import json
import pytest
from fastapi import Response
from app.api.v1 import visualization
from app.services.earth_layer_cache import (
EarthLayerCachePolicy,
apply_payload_budget,
earth_layer_cache,
format_bbox_key,
quantize_bbox,
resolve_layer_payload,
)
class FakeRedis:
def __init__(self, *, fail: bool = False) -> None:
self.store: dict[str, str] = {}
self.fail = fail
self.lock_claimed = False
def _maybe_fail(self) -> None:
if self.fail:
raise RuntimeError("redis unavailable")
def get(self, key: str):
self._maybe_fail()
return self.store.get(key)
def set(self, key: str, value: str, nx: bool = False, ex: int | None = None):
self._maybe_fail()
if nx and key in self.store:
return False
self.store[key] = value
return True
def setex(self, key: str, _seconds: int, value: str):
self._maybe_fail()
self.store[key] = value
return True
def delete(self, *keys: str):
self._maybe_fail()
deleted = 0
for key in keys:
deleted += 1 if self.store.pop(key, None) is not None else 0
return deleted
def scan_iter(self, match: str):
self._maybe_fail()
prefix = match.rstrip("*")
for key in list(self.store):
if key.startswith(prefix):
yield key
def memory_usage(self, key: str):
value = self.store.get(key, "")
return len(value.encode("utf-8"))
@pytest.fixture(autouse=True)
def fake_cache_client():
previous = earth_layer_cache._client
fake = FakeRedis()
earth_layer_cache._client = fake
try:
yield fake
finally:
earth_layer_cache._client = previous
def test_quantized_bbox_key_is_stable_for_small_movements():
first = format_bbox_key(quantize_bbox((10.01, 59.04, 10.96, 60.02)))
second = format_bbox_key(quantize_bbox((10.04, 59.01, 10.99, 60.04)))
assert first == second
assert first == "10.0,59.0,11.0,60.0"
def test_payload_budget_truncates_features():
payload = {
"type": "FeatureCollection",
"features": [{"id": index} for index in range(5)],
}
policy = EarthLayerCachePolicy(60, 120, max_features=2, max_bytes=1024)
result = apply_payload_budget(payload, policy)
assert len(result["features"]) == 2
assert result["diagnostics"]["truncated"] is True
assert result["diagnostics"]["limit_reason"] == "feature_budget"
assert result["diagnostics"]["original_feature_count"] == 5
@pytest.mark.asyncio
async def test_resolve_layer_payload_writes_fresh_and_stale(fake_cache_client):
calls = 0
async def builder():
nonlocal calls
calls += 1
return {"type": "FeatureCollection", "features": [{"id": "a"}]}
key = earth_layer_cache.key("satellites", limit="all")
policy = EarthLayerCachePolicy(60, 120)
first = await resolve_layer_payload(key=key, policy=policy, builder=builder)
second = await resolve_layer_payload(key=key, policy=policy, builder=builder)
assert first.state == "refresh"
assert second.state == "hit"
assert calls == 1
assert key in fake_cache_client.store
assert f"{key}:stale" in fake_cache_client.store
@pytest.mark.asyncio
async def test_resolve_layer_payload_returns_stale_when_builder_fails(fake_cache_client):
key = earth_layer_cache.key("bgp-incidents", status="active")
fake_cache_client.store[f"{key}:stale"] = json.dumps({"type": "FeatureCollection", "features": []})
async def builder():
raise RuntimeError("db exploded")
result = await resolve_layer_payload(
key=key,
policy=EarthLayerCachePolicy(60, 120),
builder=builder,
)
assert result.state == "stale"
assert result.payload["features"] == []
@pytest.mark.asyncio
async def test_resolve_layer_payload_uses_stale_during_lock_contention(fake_cache_client):
key = earth_layer_cache.key("cables")
fake_cache_client.store[earth_layer_cache.lock_key(key)] = "1"
fake_cache_client.store[f"{key}:stale"] = json.dumps(
{"type": "FeatureCollection", "features": [{"id": "stale-cable"}]}
)
calls = 0
async def builder():
nonlocal calls
calls += 1
return {"type": "FeatureCollection", "features": [{"id": "fresh-cable"}]}
result = await resolve_layer_payload(
key=key,
policy=EarthLayerCachePolicy(60, 120),
builder=builder,
)
assert result.state == "stale"
assert result.payload["features"][0]["id"] == "stale-cable"
assert calls == 0
@pytest.mark.asyncio
async def test_resolve_layer_payload_bypasses_redis_failure():
previous = earth_layer_cache._client
earth_layer_cache._client = FakeRedis(fail=True)
try:
async def builder():
return {"type": "FeatureCollection", "features": [{"id": "safe"}]}
result = await resolve_layer_payload(
key=earth_layer_cache.key("cables"),
policy=EarthLayerCachePolicy(60, 120),
builder=builder,
)
assert result.state == "bypass"
assert result.payload["features"][0]["id"] == "safe"
finally:
earth_layer_cache._client = previous
@pytest.mark.asyncio
async def test_visualization_endpoint_sets_cache_headers(fake_cache_client, monkeypatch):
calls = 0
async def fake_build_satellites_geojson(*, limit, db):
nonlocal calls
calls += 1
return {"type": "FeatureCollection", "features": [{"id": f"sat-{limit}"}], "count": 1}
monkeypatch.setattr(visualization, "_build_satellites_geojson", fake_build_satellites_geojson)
first_response = Response()
first = await visualization.get_satellites_geojson(limit=25, db=object(), response=first_response)
second_response = Response()
second = await visualization.get_satellites_geojson(limit=25, db=object(), response=second_response)
assert first == second
assert calls == 1
assert first_response.headers["X-Planet-Cache"] == "refresh"
assert second_response.headers["X-Planet-Cache"] == "hit"
@pytest.mark.asyncio
async def test_vessel_snapshot_uses_short_cache(fake_cache_client, monkeypatch):
calls = 0
async def fake_load_raw_vessel_snapshot_features(db, *, bbox, limit, observed_since):
nonlocal calls
calls += 1
return (
[
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [10.1, 59.1]},
"properties": {"mmsi": 123, "vessel_type_name": "Cargo"},
}
],
{"raw_feature_count": 1},
)
monkeypatch.setattr(
visualization,
"_load_raw_vessel_snapshot_features",
fake_load_raw_vessel_snapshot_features,
)
first_response = Response()
first = await visualization.build_vessel_snapshot_response(
object(),
bbox=(10.01, 59.04, 10.96, 60.02),
zoom=12,
type_filter=None,
limit=1000,
since_minutes=60,
response=first_response,
)
second_response = Response()
second = await visualization.build_vessel_snapshot_response(
object(),
bbox=(10.04, 59.01, 10.99, 60.04),
zoom=12,
type_filter=None,
limit=1000,
since_minutes=60,
response=second_response,
)
assert first["count"] == 1
assert second == first
assert calls == 1
assert first_response.headers["X-Planet-Cache"] == "refresh"
assert second_response.headers["X-Planet-Cache"] == "hit"

View File

@@ -9,6 +9,7 @@ from app.services.earth_news import (
ParsedNewsItem,
_enrich_items_with_target_locations,
_extract_target_location_from_text,
_parse_feed_entries,
_serialize_item,
get_earth_news_payload,
)
@@ -109,6 +110,60 @@ def test_serialize_item_includes_inferred_target_location():
assert payload["location_meta"]["ai_error"] is None
def test_parse_plain_rss_uses_feed_name_as_source():
source = NewsFeedSource(
id="bbc-world",
name="BBC World",
region="global",
feed_url="https://feeds.bbci.co.uk/news/world/rss.xml",
homepage_url="https://www.bbc.com/news/world",
)
xml = """
<rss>
<channel>
<item>
<title>This may be the last time you hear my voice: Political executions surge in Iran since start of war</title>
<description>Story summary</description>
<link>https://www.bbc.com/news/example</link>
<pubDate>Fri, 15 May 2026 03:00:00 GMT</pubDate>
</item>
</channel>
</rss>
"""
items = _parse_feed_entries(xml, source)
assert items[0].title == "This may be the last time you hear my voice: Political executions surge in Iran since start of war"
assert items[0].source == "BBC World"
def test_parse_aggregated_rss_splits_publisher_from_title():
source = NewsFeedSource(
id="global-scan",
name="Global Monitor / World",
region="global",
feed_url="https://news.google.com/rss",
homepage_url="https://news.google.com/",
source_type="aggregated",
)
xml = """
<rss>
<channel>
<item>
<title>Example headline - Reuters</title>
<description>Story summary</description>
<link>https://news.google.com/example</link>
</item>
</channel>
</rss>
"""
items = _parse_feed_entries(xml, source)
assert items[0].title == "Example headline"
assert items[0].source == "Reuters"
@pytest.mark.asyncio
async def test_enrich_items_with_target_locations_uses_ai_and_geocode(monkeypatch):
item = ParsedNewsItem(

View File

@@ -700,7 +700,7 @@ async def test_llm_location_fallback_repairs_non_json_answer(monkeypatch):
@pytest.mark.asyncio
async def test_llm_location_fallback_accepts_taipei_name_hint_with_weak_wording(monkeypatch):
async def test_llm_location_fallback_rejects_city_from_name_without_location_evidence(monkeypatch):
monkeypatch.setattr(
llm_fallback,
"_geocode_llm_city",
@@ -740,18 +740,60 @@ async def test_llm_location_fallback_accepts_taipei_name_hint_with_weak_wording(
entity_type="compute_center",
)
assert result.failure_reason is None
candidate = result.candidates[0]
assert candidate.city == "Taipei"
assert candidate.confidence >= 0.55
breakdown = candidate.suggested_registry_entry["llm_score_breakdown"]
assert breakdown["weak_evidence_penalty"] <= 0.15
assert breakdown["conflict_penalty"] == 0
assert breakdown["name_location_hint"] > 0
assert result.candidates == []
assert result.failure_reason is not None
assert "below minimum" in result.failure_reason
@pytest.mark.asyncio
async def test_llm_location_fallback_geocodes_city_from_entity_name_when_llm_unparseable(monkeypatch):
async def test_llm_location_fallback_accepts_explicit_facility_location_for_name_city_conflict(monkeypatch):
monkeypatch.setattr(
llm_fallback,
"_geocode_llm_city",
lambda query: {
"lat": "22.6048",
"lon": "120.3000",
"display_name": "Kaohsiung, Taiwan",
"address": {"city": "Kaohsiung", "country": "Taiwan"},
},
)
client = _FakeAIProviderClient(
json.dumps(
{
"latitude": None,
"longitude": None,
"precision": "city",
"confidence": 0.72,
"city": "Kaohsiung",
"country": "Taiwan",
"matched_location_name": "Kaohsiung, Taiwan",
"evidence": [
{
"source": "Taiwan News",
"source_type": "news",
"entity_match": True,
"text": "Nvidia's first AI supercomputer center, Taipei-1, is located in Kaohsiung.",
}
],
"reasoning_summary": "Explicit facility location evidence overrides the city-like system name.",
}
)
)
result = await collect_llm_location_fallback_candidate(
provider_client=client,
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
entity_type="compute_center",
)
assert result.failure_reason is None
candidate = result.candidates[0]
assert candidate.city == "Kaohsiung"
assert candidate.confidence >= 0.55
@pytest.mark.asyncio
async def test_llm_location_fallback_rejects_compute_center_city_from_entity_name_when_llm_unparseable(monkeypatch):
def _fake_geocode(query):
if query != "Taipei, 中国(台湾)":
return None
@@ -772,12 +814,9 @@ async def test_llm_location_fallback_geocodes_city_from_entity_name_when_llm_unp
)
assert client.calls == 2
assert result.failure_reason is None
candidate = result.candidates[0]
assert candidate.city == "Taipei"
assert candidate.latitude == pytest.approx(25.033)
assert candidate.longitude == pytest.approx(121.5654)
assert "Entity name city hint" in candidate.source_note
assert result.candidates == []
assert result.failure_reason is not None
assert "parseable city-level location fact" in result.failure_reason
@pytest.mark.asyncio

View File

@@ -5,21 +5,42 @@ import pytest
from app.api.v1 import settings as settings_api
from app.api.v1.settings import (
AIProviderIntegrationUpdate,
BarentsWatchIntegrationUpdate,
ExternalIntegrationsUpdate,
OCRIntegrationUpdate,
WebSearchIntegrationUpdate,
_build_ai_provider_payload,
_build_ocr_payload,
_can_reveal_integration_secrets,
_ensure_secret_reveal_allowed,
_mask_secret,
_normalize_ai_provider_payload,
_normalize_ocr_payload,
_record_integration_secret_reveal,
_resolve_provider_api_key,
get_runtime_ai_provider_config,
save_external_integrations_payload,
)
from app.services.llm_provider_catalog import get_fallback_llm_provider_preset
@pytest.fixture(autouse=True)
def isolated_ai_provider_env_file(monkeypatch, tmp_path):
env_file = tmp_path / ".env"
monkeypatch.setattr(settings_api, "AI_PROVIDER_ENV_FILE", env_file)
for name in (
"AI_PROVIDER",
"AI_API_KEY",
"MINIMAX_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"DEEPSEEK_API_KEY",
"DASHSCOPE_API_KEY",
"MOONSHOT_API_KEY",
"OPENROUTER_API_KEY",
"OPENCODE_GO_API_KEY",
):
monkeypatch.delenv(name, raising=False)
return env_file
@@ -56,7 +77,7 @@ def test_provider_key_prefers_specific_env_file_key(isolated_ai_provider_env_fil
def test_provider_key_falls_back_to_generic_ai_api_key(isolated_ai_provider_env_file):
isolated_ai_provider_env_file.write_text(
"AI_API_KEY=generic-env-file-key\n",
"AI_PROVIDER=openai\nAI_API_KEY=generic-env-file-key\n",
encoding="utf-8",
)
@@ -66,11 +87,101 @@ def test_provider_key_falls_back_to_generic_ai_api_key(isolated_ai_provider_env_
assert source == "env_file"
def test_generic_ai_api_key_only_applies_to_selected_env_provider(isolated_ai_provider_env_file):
isolated_ai_provider_env_file.write_text(
"AI_PROVIDER=minimax\nAI_API_KEY=generic-env-file-key\n",
encoding="utf-8",
)
openai_value, openai_source = _resolve_provider_api_key("openai", {"api_key": ""})
minimax_value, minimax_source = _resolve_provider_api_key("minimax", {"api_key": ""})
assert openai_value == ""
assert openai_source == ""
assert minimax_value == "generic-env-file-key"
assert minimax_source == "env_file"
def test_opencode_go_provider_preset_is_openai_compatible():
preset = get_fallback_llm_provider_preset("opencode-go")
assert preset["label"] == "OpenCode Go"
assert preset["provider_api"] == "openai-completions"
assert preset["base_url"] == "https://opencode.ai/zen/go/v1"
assert preset["model"] == "glm-5.1"
assert "glm-5.1" in preset["models"]
assert "deepseek-v4-flash" in preset["models"]
assert preset["model_provider_apis"]["minimax-m2.7"] == "anthropic-messages"
assert preset["api_key_env"] == "OPENCODE_GO_API_KEY"
def test_mask_secret_without_prefix_is_fully_masked():
assert _mask_secret("plainsecret")["preview"] == "***********"
assert _mask_secret("sk-prefixed")["preview"] == "sk-********"
def test_secret_reveal_permission_is_admin_only():
assert _can_reveal_integration_secrets(SimpleNamespace(role="super_admin")) is True
assert _can_reveal_integration_secrets(SimpleNamespace(role="admin")) is True
assert _can_reveal_integration_secrets(SimpleNamespace(role="viewer")) is False
@pytest.mark.asyncio
async def test_secret_reveal_denial_is_audited(monkeypatch):
events = []
async def fake_record_audit_log(**payload):
events.append(payload)
monkeypatch.setattr(settings_api, "record_audit_log", fake_record_audit_log)
user = SimpleNamespace(id=7, username="viewer", email="viewer@example.test", role="viewer")
with pytest.raises(settings_api.HTTPException) as exc:
await _ensure_secret_reveal_allowed(
current_user=user,
request=None,
target_id="ai_provider:openai",
details={"kind": "ai_provider", "provider": "openai"},
)
assert exc.value.status_code == 403
assert events[0]["action"] == "settings.integration_secret.reveal"
assert events[0]["result"] == "denied"
assert events[0]["actor_id"] == 7
assert events[0]["target_id"] == "ai_provider:openai"
@pytest.mark.asyncio
async def test_secret_reveal_audit_does_not_store_plaintext(monkeypatch):
events = []
async def fake_record_audit_log(**payload):
events.append(payload)
monkeypatch.setattr(settings_api, "record_audit_log", fake_record_audit_log)
user = SimpleNamespace(id=1, username="admin", email="admin@example.test", role="admin")
await _record_integration_secret_reveal(
current_user=user,
request=None,
target_id="ai_provider:minimax",
result="success",
details={
"kind": "ai_provider",
"provider": "minimax",
"api_key_configured": True,
"api_key_source": "env_file",
"service_token_configured": True,
"service_token_source": "runtime",
},
)
serialized = str(events[0])
assert "secret-value" not in serialized
assert events[0]["details"]["api_key_source"] == "env_file"
assert events[0]["details"]["service_token_configured"] is True
def test_build_payload_updates_only_selected_provider_key():
current = {
"ai_provider": {
@@ -109,6 +220,30 @@ def test_build_payload_updates_only_selected_provider_key():
assert payload["providers"]["minimax"]["api_key"] == "minimax-old-key"
def test_build_payload_saves_provider_without_changing_default():
current = {
"ai_provider": {
"default_provider": "minimax",
"providers": {
"minimax": {"provider": "minimax", "api_key": "minimax-key"},
"openai": {"provider": "openai", "api_key": ""},
},
}
}
update = AIProviderIntegrationUpdate(
provider="openai",
provider_api="openai-completions",
base_url="https://api.openai.com/v1",
model="gpt-test",
api_key="openai-new-key",
)
payload = _build_ai_provider_payload(current, update)
assert payload["default_provider"] == "minimax"
assert payload["providers"]["openai"]["api_key"] == "openai-new-key"
def test_build_payload_keeps_saved_key_when_preview_submitted():
current = {
"ai_provider": {
@@ -207,3 +342,78 @@ async def test_runtime_config_uses_default_provider_specific_key(monkeypatch):
assert runtime_config["llm_config"]["provider"] == "minimax"
assert runtime_config["llm_config"]["api_key"] == "minimax-key"
assert runtime_config["llm_config"]["model"] == "MiniMax-test"
@pytest.mark.asyncio
async def test_saving_ai_provider_does_not_run_connection_validation(monkeypatch):
calls = {"validated": 0, "saved": 0}
async def fake_get_setting_payload(_db, category):
assert category == "external_integrations"
return {
"ai_provider": {
"default_provider": "minimax",
"providers": {
"minimax": {
"provider": "minimax",
"api_key": "minimax-key",
},
},
},
"web_search": {},
"ocr": {},
}
async def fake_validate(_payload):
calls["validated"] += 1
raise AssertionError("save should not run AI provider connection validation")
async def fake_save_setting_payload(_db, category, payload):
assert category == "external_integrations"
calls["saved"] += 1
return payload
async def fake_get_barentswatch_config_record(_db):
return SimpleNamespace(
endpoint="",
auth_config={},
)
async def fake_serialize_external_integrations(_db):
return {"ai_provider": {"default_provider": "minimax"}}
monkeypatch.setattr(settings_api, "get_setting_payload", fake_get_setting_payload)
monkeypatch.setattr(settings_api, "_validate_ai_provider_full_connection", fake_validate)
monkeypatch.setattr(settings_api, "save_setting_payload", fake_save_setting_payload)
monkeypatch.setattr(settings_api, "get_barentswatch_config_record", fake_get_barentswatch_config_record)
monkeypatch.setattr(settings_api, "serialize_external_integrations", fake_serialize_external_integrations)
update = ExternalIntegrationsUpdate(
ai_provider=AIProviderIntegrationUpdate(
provider="minimax",
default_provider="minimax",
provider_api="anthropic-messages",
base_url="https://api.minimaxi.com/anthropic",
model="MiniMax-M2.7",
api_key="sk-new-key",
),
barentswatch=BarentsWatchIntegrationUpdate(),
web_search=WebSearchIntegrationUpdate(),
ocr=OCRIntegrationUpdate(),
)
async def fake_commit():
return None
async def fake_refresh(_record):
return None
db = SimpleNamespace(
add=lambda _record: None,
commit=fake_commit,
refresh=fake_refresh,
)
await save_external_integrations_payload(db, update)
assert calls == {"validated": 0, "saved": 1}

View 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

View File

@@ -46,6 +46,21 @@ def test_web_search_key_prefers_provider_env(isolated_web_search_env_files):
assert source == "env_file"
def test_web_search_generic_key_only_applies_to_default_provider(isolated_web_search_env_files):
isolated_web_search_env_files.write_text(
"WEB_SEARCH_API_KEY=generic-search-key\n",
encoding="utf-8",
)
default_value, default_source = _resolve_web_search_api_key("tavily", {"api_key": ""}, "tavily")
other_value, other_source = _resolve_web_search_api_key("brave", {"api_key": ""}, "tavily")
assert default_value == "generic-search-key"
assert default_source == "env_file"
assert other_value == ""
assert other_source == ""
def test_build_web_search_payload_keeps_saved_key_when_preview_submitted():
current = {
"web_search": {
@@ -71,6 +86,35 @@ def test_build_web_search_payload_keeps_saved_key_when_preview_submitted():
assert payload["providers"]["tavily"]["api_key"] == "tvly-old-secret"
def test_build_web_search_payload_saves_provider_without_changing_default():
current = {
"web_search": {
"default_provider": "tavily",
"providers": {
"tavily": {
"provider": "tavily",
"api_key": "tvly-key",
},
"brave": {
"provider": "brave",
"api_key": "",
},
},
}
}
update = WebSearchIntegrationUpdate(
enabled=True,
provider="brave",
base_url="https://api.search.brave.com/res/v1/web/search",
api_key="brave-new-key",
)
payload = _build_web_search_payload(current, update)
assert payload["default_provider"] == "tavily"
assert payload["providers"]["brave"]["api_key"] == "brave-new-key"
@pytest.mark.asyncio
async def test_tavily_adapter_normalizes_results(monkeypatch):
config = WebSearchConfig(

View File

@@ -15,12 +15,16 @@ services:
retries: 10
aiprovider:
image: ${AI_PROVIDER_IMAGE_NAME:-planet-aiprovider:latest}
build:
context: .
dockerfile: aiprovider/Dockerfile
args:
PYTHON_IMAGE: ${PYTHON_IMAGE:-python:3.14-slim}
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:latest}
AI_PROVIDER_BUILD_FINGERPRINT: ${AI_PROVIDER_BUILD_FINGERPRINT:-unknown}
secrets:
- planet_uv_config
container_name: planet_aiprovider
ports:
- "8010:8010"
@@ -43,3 +47,7 @@ services:
volumes:
ollama_data:
secrets:
planet_uv_config:
file: ${PLANET_UV_CONFIG_FILE:-/dev/null}

View File

@@ -2,12 +2,16 @@ version: '3.8'
services:
aiprovider:
image: ${AI_PROVIDER_IMAGE_NAME:-planet-aiprovider:latest}
build:
context: .
dockerfile: aiprovider/Dockerfile
args:
PYTHON_IMAGE: ${PYTHON_IMAGE:-python:3.14-slim}
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:latest}
AI_PROVIDER_BUILD_FINGERPRINT: ${AI_PROVIDER_BUILD_FINGERPRINT:-unknown}
secrets:
- planet_uv_config
env_file:
- ./aiprovider/.env
- ${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-./aiprovider/.env}
@@ -43,3 +47,7 @@ services:
volumes:
postgres_data:
redis_data:
secrets:
planet_uv_config:
file: ${PLANET_UV_CONFIG_FILE:-/dev/null}

View File

@@ -2,12 +2,16 @@ version: '3.8'
services:
aiprovider:
image: ${AI_PROVIDER_IMAGE_NAME:-planet-aiprovider:latest}
build:
context: .
dockerfile: aiprovider/Dockerfile
args:
PYTHON_IMAGE: ${PYTHON_IMAGE:-python:3.14-slim}
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:latest}
AI_PROVIDER_BUILD_FINGERPRINT: ${AI_PROVIDER_BUILD_FINGERPRINT:-unknown}
secrets:
- planet_uv_config
env_file:
- ./aiprovider/.env
- ${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-./aiprovider/.env}
@@ -53,3 +57,7 @@ services:
volumes:
postgres_data:
redis_data:
secrets:
planet_uv_config:
file: ${PLANET_UV_CONFIG_FILE:-/dev/null}

View File

@@ -8,6 +8,151 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.65.2] — 2026-05-22
Released: 2026-05-22
### Highlights
- Stabilize AI Provider rebuild detection so unchanged source no longer rebuilds just because file timestamps or local cache state changed.
- Keep `uv.lock` on the official registry while still allowing local and Docker builds to use the user's `uv.toml`.
- Prevent local startup and bootstrap commands from rewriting `uv.lock` when a user-level mirror is configured.
### Added / Fixed / Improved
- AI Provider fingerprints now use content hashes, base image inputs, dependency metadata, and an image label instead of file mtimes.
- `planet.sh` and `scripts/bootstrap-dev.sh` now use `uv sync --frozen`, and backend startup uses `uv run --frozen`.
- Docker Compose passes the AI Provider fingerprint into the image build so future starts can inspect the image label directly.
---
## [0.65.1] — 2026-05-22
Released: 2026-05-22
### Highlights
- 修复 `planet.sh` 与 Docker Compose 对 AI Provider 镜像名不一致导致的重复构建问题。
- 让本地 `uv` 与 Docker build 统一使用用户机器上的 `uv.toml`,避免把镜像源写入 `uv.lock`
- 通过 BuildKit secret 向容器构建传入 uv 配置,保留用户源选择且不把配置写入镜像层。
### Added / Fixed / Improved
- `planet.sh` 自动解析 `UV_CONFIG_FILE`、仓库 `uv.toml` 与用户级 `~/.config/uv/uv.toml`,并清理会覆盖配置的 `UV_INDEX_URL` 环境变量。
- AI Provider 和后端 Dockerfile 在 `uv sync` 阶段挂载 uv 配置 secret并复用 BuildKit uv cache。
- Docker Compose 三套配置统一声明 `planet-aiprovider:latest` 镜像名和 `planet_uv_config` build secret。
---
## [0.65.0] — 2026-05-21
Released: 2026-05-21
### Highlights
- 收敛 Admin Next 数据源触发入口:主按钮在未勾选时触发全部,勾选内置源后切换为“触发已选 N”并移除手填 ID 的批量触发弹窗。
- 优化数据源采集队列入口:右上角按钮常驻,空态显示队列图标,有任务时显示纯圆环进度,队列改为浮层避免挤压表格。
- 强化 `planet.sh destroy` 清理语义,销毁时先硬重置运行中的本地 Postgres `public` schema避免残留采集数据让 OOBE 误判 ready。
### Added / Fixed / Improved
- Admin Next 表格新增可选选择列,仅在 `/datasources` 内置源分区启用,支持当前可见行全选并在筛选、切分区或刷新时清空选择。
- 数据源批量触发复用 `/datasources/trigger-batch``source_ids`,成功后写入现有采集队列并清空勾选。
- `destroy` 补充清理 `planet-aiprovider:latest` 镜像以及 Python/Vite 等本地编译缓存,同时保留源码和 `.env`
- Docs Gatekeeper 与 Tactile UI 文档/样式继续补齐覆盖本轮按钮、队列、OOBE 和销毁流程说明。
---
## [0.64.0] — 2026-05-21
Released: 2026-05-21
### Highlights
- 新增 Earth 首次初始化 OOBE由后端真实采集状态决定是否显示避免 localStorage 清空后误弹,并提供桌面毛玻璃引导与移动端 bottom sheet。
- 数据源页新增下载列表式采集队列,把单源、批量和触发全部的任务进度统一展示,并支持失败重试与跳转详情。
- Earth 内容新增“关于”配置接口和后台 tabEarth 设置页 About 卡片改为运行时读取配置并带默认 fallback。
### Added / Fixed / Improved
- 新增 `/api/v1/earth/oobe-status``/api/v1/earth/about` GET/PUT/DELETE并让 Earth 前端加载 `about.js``oobe.js`
- Admin Next 数据源队列优先消费 `datasource_tasks` WebSocket断线时轮询 `/datasources/{id}/task-status`,刷新后只恢复后端仍在运行的真实任务。
- Admin Next 深色主题滑块补齐 Docs 同款 dark token侧栏主题控件在 dark 模式下不再保持浅色底座。
- 用户手册、快速开始、Earth 前端上下文和 Admin 前端上下文同步记录 OOBE、采集队列、About 配置与主题滑块行为。
---
## [0.63.1] — 2026-05-21
Released: 2026-05-21
### Highlights
- 修复新设备初始化后 Admin Next 无法打开的问题:补上被 `.gitignore``lib/` 规则误忽略的 Admin Next utility module。
### Added / Fixed / Improved
-`frontend/src/admin-next/lib/utils.ts` 纳入版本控制,恢复 `DashboardNext``AdminNextLayout` 等页面对 `cn``formatNumber` 的运行时依赖。
- 避免 Vite dev server 在新 clone 环境中因缺失源码模块而对 `/src/admin-next/*` 返回 500并导致动态导入 AdminNextRoutes 失败。
---
## [0.63.0] — 2026-05-21
Released: 2026-05-21
### Highlights
- 新增 `./planet.sh init` 首次初始化入口,将 uv/bun 依赖同步、env 模板补齐、数据库容器启动、建表 seed 和默认用户生成串成一条空项目引导路径。
- 新增 `./planet.sh destroy` 破坏性重置入口,带 CLI 确认保护,可清理 Planet 容器、卷、镜像和本地编译/运行状态,同时保留源码与 `.env` 配置。
- 改进 `planet.sh` 日志体验,只在带状态标签的输出行末尾追加时间戳,并让 `init` 在应用服务已运行时自动跳过重复初始化。
### Added / Fixed / Improved
- `init` 复用现有 Docker/依赖 helper支持重复执行时不覆盖 env、不清空数据并在完成后提示默认本地登录账号。
- `destroy` 停止本地服务后清理 Docker compose 状态、项目镜像、数据卷、`.venv``node_modules`、前端构建产物和 Planet state/cache。
- README 快速启动补充 `init``destroy` 命令说明,明确首次引导和重置路径。
---
## [0.62.0] — 2026-05-21
Released: 2026-05-21
### Highlights
- Admin Next 转正为正式后台入口,旧 AntD 控制台完整迁移到 `/legacy/admin/*` 作为回退和对照,并保留 `/admin-next/*` 兼容重定向。
- 新后台完成采集、AI Provider、Earth 内容、日志、BGP/告警、设置和认证链路的全量收口,新增 lazy tab loading、层级配置、移动端详情和 Markdown 文档渲染体验。
- 抽出 Tactile UI 按钮、开关、tooltip、滚动条和表格滚动组件为未来独立 npm 组件库打基础,同时补齐中英文开发文档。
### Added / Fixed / Improved
- 新增采集管理分层工作台,覆盖采集器配置、采集调度、采集历史/快照、凭证教程生成/重置、映射 propose/preview/create/activate 和真实连接/运行状态。
- 改进 AI Provider 与工具调用配置,修复 key fallback、脱敏显示、轻量连通性测试、默认 provider、OpenCode Go 路由和 Playground Markdown 输出。
- 更新 Earth 内容管理和 Earth 前台体验,支持品牌预览/上传/重置、TV 默认源与新增草稿、图层/新闻/3D 模型配置入口以及 live TV 预览。
- 强化后端数据源、系统日志、WebSocket、AI client、位置 LLM fallback、Earth layer cache 和 datasource connectivity并补充相关回归测试与运维脚本能力。
---
## [0.61.0] — 2026-05-18
Released: 2026-05-18
### Highlights
- 新增 Earth 图层 Redis 读穿缓存、防击穿锁、stale 兜底和 payload budget降低演示前重图层与船只 snapshot 对后端内存的冲击。
- 保持前端原 API 不变为海缆、登陆点、卫星、算力中心、BGP、summary 和船只 snapshot 增加透明缓存 header 可观测性。
- 新增 super admin Earth 图层缓存状态与清理接口,并在采集写入后按 source 主动失效相关缓存。
### Added / Fixed / Improved
- 新增 `earth:layer:v1:*` 缓存命名空间、fresh/stale 双 key、Redis 故障 bypass 和 OOM 防护诊断。
- 船只 snapshot 使用短 TTL、bbox 量化和响应预算,避免重复视窗请求和超大 payload 触发后端 OOM。
- 补充 Earth layer cache 计划文档与后端测试,覆盖 hit、refresh、stale、bypass、锁竞争、cache header 和运维清理。
---
## [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

View File

@@ -35,7 +35,11 @@
- [Docs Gatekeeper 鉴权系统计划](/home/ray/dev/linkong/planet/docs/plans/docs-gatekeeper-auth-plan.md)
- [Location Resolver 共享管线计划](/home/ray/dev/linkong/planet/docs/plans/location-resolver-shared-pipeline-plan.md)
- [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
- [AI Provider OpenClaw-Style Routing Plan](/home/ray/dev/linkong/planet/docs/plans/ai-provider-openclaw-style-routing-plan.md)
- [统一集成配置 Schema 系统计划](/home/ray/dev/linkong/planet/docs/plans/integration-config-schema-system-plan.md)
- [Lightweight Agent Orchestrator 与 WebSearch 证据层计划](/home/ray/dev/linkong/planet/docs/plans/agents-light-orchestrator-websearch-plan.md)
- [Admin Next Parity Checklist](/home/ray/dev/linkong/planet/docs/plans/admin-next-parity-checklist.md)
- [Admin Next Parity Audit Closeout](/home/ray/dev/linkong/planet/docs/plans/admin-next-parity-audit-closeout-plan.md)
- [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md)
不适合放入这里的内容:

View File

@@ -0,0 +1,128 @@
# Admin Next 双轨全量迁移修正计划
## Summary
`/admin-next/*` 继续作为新版影子路由开发,旧 AntD 控制台必须完整保留作为生产回退和行为对照。只有新版完成全功能 parity 并通过验收后,才能逐个切换旧路由;删除旧页面和移除 AntD 必须作为最后独立步骤,并等待明确确认。
## Hard Rules
- 未完成 parity checklist 前,不删除旧页面、不移除 AntD、不切旧入口。
- “旧版能删除”只表示新版能力达到替代标准,不表示立即删除旧版。
- 新版不能只做看板旧版里可新增、编辑、删除、测试、触发、批量、轮询、WebSocket、权限控制、错误提示和确认弹窗的能力都必须迁移。
- `/admin-next/*` 可以重做交互和视觉但业务语义、API payload、权限和危险操作保护必须对齐旧版。
## Design Direction
- 风格采用“朴素工具化 + 轻微立体触感”:灰白/暗色低对比背景、清晰 1px 边框、轻微凸起/按下态、克制圆角、图标少量彩色。
- 禁止回到 soft-glass、hero、大发光、大渐变、大色块和装饰性卡片。
- 常规动作默认 icon-only + tooltip刷新、重启、退出、复制、查看、编辑、删除、关闭、设置。
- 强意图动作保留实心文字按钮:保存、创建、确认、执行、测试连接。
- 页面统一 `16px` 外边距、`16px` 主区块间距、`12px` 面板 header/body 间距;表格标题和表体不能贴边。
- 所有页面遵守一屏工作台:`PageHeader` 之外只允许一个主内容区域吃满剩余高度;详情区、表格区和 Playground 消息区内部滚动,不能被父级裁掉。
- 页内 tabs 必须按旧版用户心智组织,而不是按后端接口名拆散;接口状态只能作为分区内部信息。
## Implementation Plan
1. 恢复旧版安全网:恢复旧 AntD 页面、旧 `AppLayout`、旧 helper、AntD 依赖和旧路由;保留 `/admin-next/*` 影子路由。
2. 建立新版基础层:整理 admin-next token、按钮、tooltip、theme switch、sidebar account、`EntityTable``FormDialog``ConfirmDialog``DetailPanel`、移动端表格/卡片切换。
3. 拆掉通用看板页每个模块建立真实业务页面、API adapter、form/mutation、轮询或 WebSocket 逻辑,不再用万能 Resource table 聚合展示。
4. 按模块迁移:
- Dashboard统计、WebSocket、健康检查、重启任务、任务日志、恢复探测。
- DataSources内置源/自定义源/实时源、详情、stats、task-status、启停、触发、批量触发、清理数据。
- DataList列表、summary、sources/types/countries、搜索、筛选、分页、详情、导出、分布。
- Collection Managementconfigs CRUD、builtin connect/test、target schemas、mappings、run/stop、stream status。
- Settingssystem、notifications、security、SMTP、TV、integrations、collectors、测试连接、凭证指南。
- AIprovider、web search、OCR、prompt registry、playground 完整会话操作。
- Earth Contentbrand、upload、delete/reset、boundary config/build/status、TV/content。
- Logssources、level/date/search、刷新、详情、复制、tail/snapshot。
- BGPoverview、collectors、incidents、anomalies、events、AI briefs。
- Alerts系统告警、BGP 告警、态势告警,包含 ack/resolve/stats/brief。
- Userslist/create/edit/delete、role、Gatekeeper groups、权限显示。
- Auth登录、注册、验证邮箱、重发、找回、重置、logout。
5. 每完成一个模块,对照旧页面跑 checklist只有全部模块通过后才提出旧路由切换和旧版删除。
## Test Plan
- 恢复阶段:`cd frontend && /home/ray/.bun/bin/bun install && /home/ray/.bun/bin/bun run build`
- 旧路由检查:`/admin``/datasources``/settings``/ai``/logs` 必须继续打开旧版。
- 新路由检查:`/admin-next/*` 必须继续打开新版。
- 每模块 parity读写删、测试连接、触发、批量、轮询、WebSocket、权限、错误提示、确认弹窗、移动端和滚动。
- 视觉检查light/dark/system、低高度窗口、125%/150% 缩放、按钮/状态/badge 等高、无双滚动条。
## Parity Checklist
### Dashboard
- [x] `/dashboard/stats` 数据展示与旧版一致。
- [x] WebSocket 连接状态、健康状态、自动刷新语义一致。
- [x] 重启任务创建、轮询、日志查看、失败提示和恢复探测一致。
### DataSources
- [x] 内置源、自定义源、实时源分区清晰,不混表。
- [x] 详情、stats、task-status、enable/disable、trigger、trigger-batch、trigger-all、delete-data 与旧版一致。
- [x] 实时源 start/stop/restart、连接状态、任务状态与旧版一致。
### DataList
- [x] 列表、summary、sources/types/countries、搜索、筛选、分页参数与旧版一致。
- [x] 详情 metadata、分布摘要、JSON/CSV 导出与旧版一致。
- [x] 表格固定列、横向滚动、移动端卡片模式可用。
### Collection Management
- [x] configs/all、configs CRUD、builtin connect/test、custom sample 与旧版一致。
- [x] target schemas、mappings preview/list/update/activate 与旧版一致propose/create 已提供表单化入口。
- [x] run-mapped、stop-mapped、stream-status 与旧版一致。
### Settings
- [x] system、notifications、security、SMTP、TV、integrations、collectors 表单字段完整。
- [x] SMTP test、AI/Web/OCR secrets、connect tests、preset refresh 与旧版一致。
- [x] credential guides、cache 清理、危险操作确认一致。
### AI
- [x] Provider、Web Search、OCR、Prompt Registry 配置保存/重置完整。
- [x] Playground thread/session/messages/status/stop/resend/edit 完整。
- [x] provider refresh、connect test、secrets 读取、错误提示和 loading 状态完整。
- [x] 页内 tabs 按旧版心智恢复为模型供应商、工具调用、提示词、Playground。
### Earth Content
- [x] brand get/save/delete/reset/upload 完整。
- [x] boundary status/config/build/build-status 完整。
- [x] TV/content 配置与旧版 Settings 入口一致。
### Logs
- [x] sources、level/date/search、limit、刷新参数与旧版一致。
- [x] 日志正文、详情展开、复制、空态和错误态完整。
### BGP
- [x] overview、collectors、incidents、anomalies、events 的 summary/list/detail 完整。
- [x] AI brief list/detail/generate 完整。
- [x] collector collect-location、刷新、结果详情和错误提示完整。
### Alerts
- [x] 系统告警 list/detail/ack/resolve/stats/AI brief 完整。
- [x] BGP incidents/anomalies/brief 完整。
- [x] 态势告警 stats/list/brief 完整。
### Users
- [x] list/create/edit/delete、role、Gatekeeper groups 完整。
- [x] 权限显示、普通用户限制和 super admin 行为一致。
### Auth
- [x] login/register/verify/resend cooldown/forgot/reset/logout/me 完整。
- [x] 未验证邮箱跳转、错误提示、成功跳转和 token store 行为一致。
### Hierarchy Repair
- [x] AI 不再按接口/状态拍平成一张表;模型供应商恢复为 provider -> model/API/key 层级。
- [x] 工具调用恢复为 Web Search/OCR -> provider/API/高级参数层级。
- [x] 提示词恢复为 group -> prompt 入口层级,保存/重置只作用于当前入口。
- [x] Settings/Earth 配置型页面使用父级列表 + 右侧正式表单,长内容在一屏内内部滚动。
### Interaction Polish
- [x] 不会产生歧义的刷新、复制、详情、批量、状态、清理等动作优先改为 icon-only + tooltip/title。
- [x] 强意图动作保留实心文字按钮,例如保存、创建、触发全部、生成简报、确认执行。
- [x] 按钮字号、高度、图标容器和 hover 触感统一,接近侧栏深色模式滑块的轻微立体风格。
- [x] 表格与详情之间增加可拖动竖向 resize handle低高度和窄屏下不制造额外滚动条。
### Page Information Architecture
- [x] 页内 tab 默认使用中文BGP、AI、OCR、Web Search、Playground、Schema 等专有名词按可识别性保留。
- [x] 信息观测类页面使用列表 + 详情,例如 BGP、Alerts、Logs、DataSources。
- [x] 配置管理类页面使用分层结构,例如 AI、Settings、Earth Content、Collection Management。
- [x] 采集管理已从纯表格看板改为分层管理:采集器、映射模板、目标 Schema、运行状态先选父级再编辑或执行动作。

View File

@@ -0,0 +1,135 @@
# Admin Next Parity Audit Closeout
Last updated: 2026-05-21
## Status
Admin Next has been promoted to the official admin route family. The old AntD admin, old layout helpers, `antd`, and `@ant-design/icons` remain available under `/legacy/admin/*` as the rollback and comparison surface.
`/admin-next/*` is now compatibility-only: old test links redirect to the official route. Do not add new capabilities there as a separate entry point.
This closeout document is the final work log for the second parity audit. It records what has been fixed, what still requires manual verification, and what must not be treated as complete.
## Fixed In This Audit
- Restored the dual-track rule: old AntD pages and routes are not deleted and are not replaced before parity sign-off.
- Reworked management pages away from flat status tables toward business hierarchy:
- AI: `模型供应商 / 工具调用 / 提示词 / Playground`.
- Settings: system display, notification policy, security policy, SMTP only.
- Earth Content: brand, boundary precision, TV content.
- Collection: collector, collection schedule, history/snapshot.
- Repaired AI provider credential semantics:
- Provider status now comes from that provider's stored key or a runtime key that is explicitly scoped to that provider.
- Generic `.env` fallback no longer marks every provider as configured.
- Runtime fallback secrets stay masked by default; authorized administrators can reveal them through the console and each reveal must be audited without storing plaintext.
- Secret previews preserve the prefix before the first `-` when the backend preview exposes it, for example `sk-**********`.
- Connect-test buttons are icon-only plug actions and do not save configuration.
- Repaired key UI gaps:
- LLM API Key, proxy token, Web Search API Key, and OCR API Key use masked input with inline eye toggle.
- Save buttons use the disk icon.
- Default action is disabled when the item is already default.
- Repaired SMTP test flow:
- SMTP test has a recipient input.
- Request payload is `{ to, settings }`.
- Repaired Earth Content actions:
- Brand save, upload, delete/reset actions are available in the brand section.
- Brand preview renders with the same Earth left-top brand structure and starfield-style background.
- Boundary build is scoped to the boundary precision section, not the global page toolbar.
- TV default configuration is folded into item-level configuration; TV items support add/default/reset/delete semantics.
- TV default source now follows the same default-state semantics as AI Provider: if the response does not expose an explicit default, `cgtn-en` is treated as the runtime default; setting a default promotes that item visually and disables the redundant default action.
- Repaired Collection actions:
- Credential guide read/generate/reset entry points are available from collector-related panels.
- Create collector config uses field-first form controls with advanced JSON as a secondary path.
- Mapping propose/preview/create/activate flow is represented in the mapping workflow.
- Repaired shared UI rules:
- Management list names can wrap and remain readable instead of being squeezed by status tags.
- Status tags are fixed width and color-coded: default blue, configured green, unconfigured gray, error red.
- Configuration lists with a default item sort the default item to the top after save/default changes instead of leaving it in the previous alphabetical or API order.
- List footer actions are part of the scroll content; users see them after scrolling to the bottom.
- Footer icon buttons use the current tactile surface direction: external shadow first, without a separate inset/base-plate treatment.
- Detail panels use a dark neutral resize handle.
- Tooltip, dialog, modal backdrop, textarea scrollbar, custom scrollbar, mobile detail, and one-screen layout issues from the audit have been addressed in the shared admin-next layer.
- Multi-tab Admin Next pages now use lazy active-tab loading with local cache. Initial page load no longer requests every section endpoint; manual refresh and mutating actions refresh only the current section.
- Markdown-producing details, AI brief content, credential tutorials, and Playground assistant output use the shared Markdown renderer where the content is meant to be read as a document. Raw metadata remains available only where the page is explicitly showing original payloads.
- Added developer workflow scripts:
- `build:watch`
- `preview:auto`
- `bun run build` remains a production artifact build and does not reload an already-open dev page by itself.
## Manual Verification Status
The code has been updated. The list below records which areas are already manually checked and which still need route-level regression before replacement:
- AI:
- Latest manual pass marked provider status, secret reveal, and Playground behavior as checked.
- DataSources:
- Single trigger performs old task-status precheck, handles running-conflict confirmation, supports force recollect, and refreshes task status afterward.
- Batch trigger uses old `/datasources/trigger-batch` semantics for selected IDs, filters, and force.
- Builtin, custom, and realtime source actions match old enable/disable/start/stop/restart/clear-data behavior.
- 2026-05-20 implementation pass: Admin Next builtin datasource rows now expose task/collection status and metrics, and single trigger now performs task-status precheck with running-conflict force confirmation plus post-trigger task refresh.
- 2026-05-20 manual pass: builtin trigger, task-status precheck, force recollect, batch trigger, enable/disable, and clear-data were verified in Admin Next.
- Collection:
- Collector config create/update/delete, builtin connect, custom test/sample/run/stop/status, schedule save, history/snapshot, mapping propose/preview/create/activate, and credential guide read/generate/reset all work with real backend payloads.
- 2026-05-20 implementation pass: `采集历史 / 快照` now reads real datasource snapshots from `/datasources/snapshots` instead of config rows.
- 2026-05-20 implementation pass: default credential guide actions no longer call unsupported providers blindly; unsupported collectors show a clear empty state and supported providers use `barentswatch` / `aisstream`.
- Collection action buttons must follow the AI page's single-responsibility rule: navigation/link actions do not save, save actions do not connect/test, and connect/test actions do not persist drafts.
- 2026-05-20 manual pass: collector create/edit `auth_config`, mapping propose/preview/create/activate, custom test/sample/run/stop/status, credential guide read/generate/reset, and history/snapshot behavior were verified in Admin Next.
- 2026-05-20 follow-up: dead JSON modal create/mapping code was removed so Collection creation now goes through the list-bottom draft workflow only.
- Earth Content:
- Brand preview visually matches the Earth runtime left-top brand at the same structural level.
- Brand upload writes back `logo_src` or `title_src` correctly.
- 2026-05-20 implementation pass: Brand upload validates and displays accepted suffixes (`png`, `jpg`, `jpeg`, `webp`, `svg`) and supports drag-and-drop.
- Boundary status/config/build and TV add/save/default/delete/reset match old behavior.
- Logs:
- Sources, snapshot/tail, level/date/search, copy, empty/error states, and internal scrolling all work.
- 2026-05-20 implementation pass: Logs now include database-backed `system-db` and `audit-db` sources as a fallback when file/docker/buffer logs are empty or missing, and the UI auto-selects an available source. Manual regression must still verify snapshot/tail-like reading, filters, copy, and scrolling.
- BGP / Alerts:
- BGP overview, collectors, collect-location detail, incidents, anomalies, events, latest/detail/generate AI brief.
- System/BGP/situational alert detail, ack, resolve with note, refresh after mutation, and AI brief details.
- Users/Auth:
- Users CRUD, role, Gatekeeper groups.
- 2026-05-20 implementation pass: User edit select dropdowns now use an opaque high-z-index menu surface, and Gatekeeper checkbox groups use an explicit surface background. Manual visual regression is still required.
- Login, register, verify email, resend cooldown, forgot/reset password.
- Mobile:
- 375px, 430px, and 768px widths default to list/group first.
- Detail opens only after tap/click and has a visible back action.
- Long forms, JSON, logs, and Playground messages scroll internally without body-level double scrollbars.
## Explicit Non-Production Items
The old AntD page itself treated these Earth content tabs as placeholder-level capabilities. Admin Next must show a visible “backend capability pending” empty state and must not mix in unrelated data:
- `basemap`
- `layer_resources`
- `models_3d`
- `news_anchor_strategy`
If backend endpoints are later added, these items must be promoted into `docs/plans/admin-next-parity-checklist.md` with concrete API and UI acceptance criteria.
## Final Gate
After the route promotion, the final gate is no longer “switch old routes.” The remaining gate is “keep legacy available until the promoted routes are stable enough to remove AntD.”
1. Run `cd frontend && /home/ray/.bun/bin/bun run build`.
2. Run the static checks:
- `rg "map: \\(\\) => \\[\\]|暂不支持保存|placeholder" frontend/src/admin-next`
- `rg "ShadowPage|FeatureConsole|GlassPanel|InspectorDrawer" frontend/src/admin-next`
3. Manually verify every official route listed in `docs/plans/admin-next-parity-checklist.md`.
4. Confirm `/legacy/admin/*` still opens old AntD pages during the validation window.
5. Delete old AntD pages and remove AntD dependencies only as a separate final cleanup task after explicit confirmation.
## Current Build Verification
Last successful local build during this closeout, after the 2026-05-20 follow-up fixes:
```bash
cd frontend && /home/ray/.bun/bin/bun run build
```
Result: passed.
Backend syntax check also passed:
```bash
/home/ray/.local/bin/uv run python -m py_compile backend/app/api/v1/datasources.py backend/app/api/v1/datasource_config.py backend/app/api/v1/system_control.py
```

View File

@@ -0,0 +1,43 @@
# Admin Next Parity Checklist
Last updated: 2026-05-21
This checklist is the hard gate for deleting the old AntD admin. Admin Next now owns the official admin routes; old AntD pages stay available under `/legacy/admin/*` until every production capability below is verified.
## Route Gate
- `/admin` Dashboard: stats, health probe, WebSocket status, restart task, restart logs, recovery probe.
- `/datasources`: builtin sources, custom sources, realtime sources, detail/stat/task-status, trigger, batch trigger, enable/disable, clear data, realtime start/stop/restart, custom source test/sample/run/stop/status link.
- `/data`: list, summary, source/type/country filters, search, pagination, detail metadata, export, distribution chart.
- `/collection-management`: collector config, mapping templates, target schemas, collection schedule, history/snapshot, create config, propose/preview/create/activate mapping, builtin connect, run/stop/stream-status, credential guide read/generate/reset.
- `/settings`: system display, notifications, security, SMTP get/save/test with recipient.
- `/ai`: model providers, tool calling, prompts, key reveal, provider refresh, connect tests, OCR/WebSearch secrets, Playground thread/chat/edit/resend/stop.
- `/earth-content`: brand preview as Earth renders it, upload/save/delete/reset, boundary status/build/config, TV source add/save/default/delete/reset.
- `/logs`: sources, filters, snapshot, empty/error states, copy.
- `/bgp`: overview, collectors, collect-location, incidents, anomalies, events, AI brief list/latest/detail/generate.
- `/alerts/system`, `/alerts/bgp`, `/alerts/situational`: list/stat/detail, ack, resolve with note, AI brief generation.
- `/users`: list/create/edit/delete, role, Gatekeeper permission groups.
- Auth pages: login, register, verify email, resend cooldown, forgot/reset password.
Compatibility routes under `/admin-next/*` should redirect to these official paths and are not a separate validation target.
## Verified Manual Passes
- 2026-05-20 `/admin-next/collection-management`: user verified collector create/edit payloads, mapping propose/preview/create/activate, custom collector test/sample/run/stop/status, credential guide read/generate/reset, and collection history/snapshot behavior after the draft-form and Time Capsule updates.
- 2026-05-20 `/admin-next/datasources`: user verified builtin source trigger, task-status precheck, force recollect, batch trigger, enable/disable, and clear-data behavior.
## Known Non-Production Tabs
The old AntD page also marked these Earth content tabs as placeholder-level capabilities. Admin Next must show them as “backend capability pending” and must not mix in unrelated data:
- `basemap`
- `layer_resources`
- `models_3d`
- `news_anchor_strategy`
## Replacement Rules
- Keep `/legacy/admin/*` available until the matching route gate above is manually verified after promotion.
- Do not delete old AntD pages, old layout helpers, `antd`, or `@ant-design/icons` until final explicit confirmation.
- Do not use fake rows, unrelated endpoint data, or empty adapters for a real old capability.
- Any backend-missing capability must be recorded here with the missing endpoint and visible UI empty state.

View File

@@ -0,0 +1,217 @@
# Admin Next Soft Glass Goal-Driven Plan
## Goal
Rebuild `/admin-next/*` into a modern soft-glass / light-neumorphic console while keeping the legacy Ant Design admin routes available for comparison. The new console must use visual references only as design anchors, not as imported templates, and must be implemented as Planet-owned reusable components and page patterns.
The redesign must cover desktop and mobile. Data display, icon semantics, table readability, and scroll behavior are first-class acceptance criteria.
## Criteria For Success
- This plan exists at `docs/plans/admin-next-soft-glass-goal-driven-plan.md`.
- `/admin-next/*` has real pages for every route; route usage of `ShadowPage` is removed.
- Admin Next supports `system`, `light`, and `dark` theme modes using the same persistence and system-theme idea as Docs.
- The visual language reads as soft-glass / light-neumorphic instead of an AntD reskin: translucent panels, fine borders, subtle glow, cool backgrounds, restrained accent colors, crisp icons, and tactile controls.
- Mobile is explicitly designed: mobile navigation, filter sheets/chips, card-list data views, full-screen detail sheets, and bottom action bars instead of squeezed desktop tables.
- Tables do not show double scrollbars. Long fields are inspectable, copyable, or expandable; important columns stay readable.
- Existing custom scrollbar components remain the default scroll experience.
- `cd frontend && bun run build` passes.
- Static checks can prove no route-level placeholder remains.
## Reference Strategy
Use references to calibrate feel, not to take over code:
- User-provided soft-glass / light-neumorphic dashboard images define the desired mood.
- Shadcn / Tailwind / glassmorphism admin templates may be inspected for shadow, spacing, panel, dark-mode, and app-shell ideas.
- Do not import a complete template, router, auth layer, state layer, or business page.
- Planet-specific interactions such as datasource tasks, BGP briefs, AI settings, log streams, Earth resources, and custom scrollbars are implemented in this repo.
## Source-Of-Truth Mining
Design content and page boundaries from existing repo history and docs:
- `docs/CHANGELOG.md`
- `docs/plans/*`
- `docs/technical/*frontend*`
- `TODO.md`
Important current boundaries:
- `DataSources` is a datasource directory and runtime-control page, not a configuration editor.
- `Collection Management` owns collector endpoint, credential, custom source, mapping, and runtime configuration.
- `Earth Content` owns TV livestreams, brand assets, boundary precision, basemap, and Earth display resources.
- `AI` owns provider settings, tools, prompts, and Playground.
- `BGP` is an observability and evidence workspace, not just tables.
- `Alerts` is a duty/analysis workspace for system, BGP, and situational risk.
- `Logs` should become a usable log workbench with filtering and structured detail.
- `Settings` should keep platform settings only.
## Reusable Architecture
Recommended structure:
- `admin-next/design`: theme tokens, theme mode, status colors, icon semantics, chart themes, scrollbar themes.
- `admin-next/components`: primitive UI such as button, input, select, tabs, dialog, drawer, badge, status pill, icon badge.
- `admin-next/patterns`: page patterns such as `PageFrame`, `GlassPanel`, `MetricCard`, `CommandBar`, `FilterRail`, `InspectorDrawer`, `DataWorkspace`, `SettingsWorkspace`, `ScrollableRegion`, `TableRegion`, and `MobileSheet`.
- `admin-next/features`: business modules such as datasources, data-list, bgp, alerts, ai, logs, settings, earth-content, collection-management, users.
- `admin-next/services`: feature API gateways and hooks. Pages should not scatter URL construction.
- `admin-next/routes`: route manifest used by navigation, command search, breadcrumbs, and legacy links.
## Interaction Rules
- Row click opens an inspector; frequent row actions live in the inspector header.
- Bulk selection opens a floating command bar.
- Common filters stay visible; advanced filters open a sheet/drawer.
- Detail, JSON, log context, and AI brief use inspector or reading panels instead of stacked modal dialogs.
- Settings are split into `FormSection` blocks with local save, dirty state, connection test, and reset.
- Dangerous actions use a confirm sheet.
- Loading, empty, error, and retry states are shared through reusable patterns.
## Mobile Rules
- Do not squeeze desktop layout into mobile.
- Use mobile navigation and sheets instead of a permanent desktop sidebar.
- Filters collapse into a filter sheet; active filters remain visible as chips.
- Data workspaces default to card-list mode on mobile and allow explicit table mode for dense comparison.
- Details open in full-screen mobile sheets.
- Batch actions use a sticky bottom command bar.
- Long logs and JSON use full-screen reading/editing surfaces with custom scrollbars.
## Table And Data Display Rules
- Tables must prioritize complete data display:
- key columns get stable widths and priority;
- long fields have peek/expand or inspector detail;
- IDs, URLs, metadata, and errors are copyable;
- complex fields are rendered fully in inspector.
- Desktop tables use one `TableRegion` scroll container.
- Mobile defaults to card-list display and supports dense table mode.
- Native scrollbar visuals are hidden where custom scrollbars apply.
## Icon Rules
- Use `lucide-react`.
- Define icon semantics by category:
- navigation icon;
- entity icon;
- status icon;
- action icon;
- severity icon.
- Icons must not be the only semantic carrier unless the icon is universally obvious; use labels or tooltips.
- Status icons pair with `StatusPill`.
- Colors come from tokens, not ad hoc hard-coded values.
## Page Design
### Dashboard
- System health, realtime connection, datasource activity, task trend, alert summary, recent events, and quick commands.
- Restart flow uses a confirm sheet and log console.
- Mobile uses horizontal metric cards and collapsible event/command panels.
### DataSources
- Datasource directory and runtime control.
- Desktop: overview rail, datasource table/list, inspector.
- Mobile: datasource cards with status, latest task, trigger/toggle actions.
- Batch trigger uses command bar.
- Endpoint/config/task details are shown fully in inspector.
### DataList
- Collection result browsing, search, source/type filters, paging, distribution chart, and detail inspector.
- Fix double scrollbars.
- Mobile defaults to cards and allows table mode.
### BGP
- Views: collectors, incidents, anomalies, events, AI brief.
- Emphasize severity, region, ASN/prefix, collector coverage, and evidence.
- AI brief shows facts, judgment, and evidence gaps.
### Alerts
- Real pages for system, BGP, and situational alerts.
- Shared alert workspace with stats, severity/status filters, list, and inspector.
- AI brief appears in inspector/mobile sheet.
### AI
- Provider, tools, prompts, and Playground.
- Provider and tool settings use local save and connection tests.
- Prompt registry is grouped by task.
- Mobile Playground uses step tabs rather than cramped columns.
### Logs
- Log workbench with source, level, date, search, refresh, copy, and structured detail.
- Desktop uses a terminal-like log stream.
- Mobile rows expand or open a full-screen reading sheet.
### Users
- Search, role filter, create/edit/delete, Gatekeeper groups.
- Edit/create in drawer/sheet.
- Gatekeeper groups use checkbox chips.
### Settings
- Platform settings only: system, notification, security, SMTP.
- Each section saves independently.
### Earth Content
- TV livestreams, brand assets, boundary precision, basemap, and layer resources.
- Resource previews, upload state, and boundary build status are emphasized.
### Collection Management
- Collector config, custom sources, mapping, runtime control.
- Custom source creation uses a step sheet.
- Advanced JSON is isolated in a collapsible editor.
## Milestones
1. Save this plan and establish goal-driven criteria.
2. Build design/theme/pattern foundations.
3. Remove route-level placeholders and create real pages for all `/admin-next/*` routes.
4. Redesign layout, theme, mobile shell, scroll behavior, and table behavior.
5. Fill core workspaces with real API calls and reusable patterns.
6. Verify build, no `ShadowPage` route usage, and core route availability.
## Verification
Run:
```bash
cd frontend && bun run build
rg "ShadowPage" frontend/src/admin-next
rg "axios\\." frontend/src/admin-next/pages
rg "overflow: auto|overflow-y: auto" frontend/src/admin-next
```
Manual route checks:
- `/admin-next`
- `/admin-next/datasources`
- `/admin-next/data`
- `/admin-next/bgp`
- `/admin-next/alerts/system`
- `/admin-next/alerts/bgp`
- `/admin-next/alerts/situational`
- `/admin-next/ai`
- `/admin-next/logs`
- `/admin-next/users`
- `/admin-next/settings`
- `/admin-next/earth-content`
- `/admin-next/collection-management`
Manual viewport checks:
- desktop;
- mobile width;
- low height;
- 125% / 150% browser zoom;
- light / dark / system theme modes.

View 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.

View File

@@ -0,0 +1,182 @@
# AI Provider OpenClaw-Style Routing Plan
Last updated: 2026-05-20
## Summary
Planet 的 AI Provider 路由要从“运行时识别特殊 provider / 特殊模型”收敛到 OpenClaw 风格的配置驱动模型:模型引用、协议、鉴权、轻量探测、真实调用和模型级例外都由 provider catalog / preset / runtime metadata 描述,运行时只解释这些元数据,不再散落 `if provider == ... and model == ...` 这类硬编码。
这份计划覆盖 Admin Next 的 Provider 配置体验、backend settings API、`aiprovider` 适配服务和未来模型目录同步方式。目标是让 OpenCode Go、MiniMax、DeepSeek、OpenAI-compatible、Anthropic-compatible、Ollama、OpenRouter / One API 类代理都能用同一套规则扩展。
## Background
当前实现已经完成了两步临时修正:
- OpenCode Go 模型目录不再使用普通 Zen free 列表,而是使用 `https://opencode.ai/zen/go/v1/models`
- `minimax-m2.7` / `minimax-m2.5` 的协议例外已从 `aiprovider` 运行逻辑移到 `model_provider_apis` 元数据中。
但整体还没有完全达到 OpenClaw 式结构。OpenClaw 的关键思想是:
- 模型引用使用 `provider/model`,由 provider 前缀确定 runtime provider。
- provider 插件或 catalog 拥有 `normalizeModelId``normalizeTransport``normalizeConfig``prepareRuntimeAuth``createStreamFn` 等 provider 行为。
- 主推理循环不认识具体模型名,只使用解析后的 provider config、transport 和 request adapter。
- 上游网关能自己路由时,尽量透传 provider routing metadata不在本地复制上游逻辑。
Planet 不需要完整复制 OpenClaw 插件系统,但需要学习它的边界划分。
## Design Principles
- Provider catalog 是路由事实来源runtime 不是。
- 模型级协议例外必须是 metadata例如 `model_provider_apis`,不能是 Python set / if 分支。
- 轻量连通性测试只验证网络、鉴权和模型目录,不发真实 prompt。
- 真实模型调用只发生在 Playground、AI brief、分析任务等明确需要生成的路径。
- 保存配置不自动设为默认,不自动触发连接测试;保存、设默认、测试三种按钮职责分离。
- 目录刷新使用增量合并语义:发现新模型,标记旧模型 stale不直接删除用户选择或自定义模型。
- 如果 provider 不提供可靠 `/models`,可以用内置 preset 确认已知模型,但 UI 必须说清楚这是 preset confirmation不是假装 provider 返回了目录。
## Target Data Model
Provider preset / runtime config 应逐步收敛为类似结构:
```json
{
"provider": "opencode-go",
"label": "OpenCode Go",
"default_transport": "openai-completions",
"base_url": "https://opencode.ai/zen/go/v1",
"auth": {
"type": "bearer",
"api_key_env": "OPENCODE_GO_API_KEY"
},
"models": [
{
"id": "glm-5.1",
"label": "GLM 5.1",
"transport": "openai-completions",
"context_window": null,
"capabilities": ["text"]
},
{
"id": "minimax-m2.7",
"label": "MiniMax M2.7",
"transport": "anthropic-messages",
"capabilities": ["text", "reasoning"]
}
],
"discovery": {
"type": "openai-models",
"url": "https://opencode.ai/zen/go/v1/models",
"auth": "provider-api-key"
}
}
```
Runtime 选择规则:
1. 解析 provider。
2. 解析 model。
3.`models[].transport` 找模型级 transport。
4. 若没有模型级 transport使用 provider `default_transport`
5. 将解析结果传给 `aiprovider`
6. `aiprovider` 只按 `transport` 组装请求,不认识 provider 专属模型名。
## Implementation Plan
### Phase 1: Stabilize Current Metadata Path
- Keep `model_provider_apis` as the immediate compatibility bridge.
- Ensure `_provider_defaults()` includes provider metadata such as `model_provider_apis`.
- Ensure `_runtime_config_from_ai_payload()` sends the resolved metadata through `AIProviderClient`.
- Ensure `AIProviderClient` forwards metadata to `aiprovider` with a structured header.
- Ensure `aiprovider.ProviderService` reads model metadata and resolves `provider_api = model_provider_apis[model] ?? provider_api`.
- Add tests proving `aiprovider` does not contain provider/model-specific literals for routing decisions.
### Phase 2: Replace `model_provider_apis` With Structured Model Catalog
- Extend `backend/app/services/llm_provider_catalog.py` preset shape with `models_metadata`.
- Preserve old `models` as a compatibility list for the UI.
- Add helpers:
- `get_provider_model_metadata(provider, model)`
- `resolve_provider_transport(provider_config, model)`
- `merge_discovered_models(existing, discovered)`
- Return both `models` and `models_metadata` from refresh endpoints.
- Admin Next should render model labels, capabilities and transport hints from metadata.
### Phase 3: Provider Discovery And Incremental Sync
- Add provider discovery descriptors:
- OpenAI-compatible `/models`
- Anthropic-compatible no-models / preset-confirmed path
- Ollama `/api/tags`
- OpenCode Go `/zen/go/v1/models`
- OpenRouter / One API passthrough model discovery
- Add incremental merge behavior:
- New discovered model: add.
- Existing discovered model: update `last_seen_at`, metadata.
- Missing discovered model: mark `stale`, do not delete.
- User custom model: keep unless explicitly removed.
- Surface discovery source in Admin Next: `实时发现 / 内置预设 / 用户自定义 / 已过期`.
### Phase 4: Transport Adapters
- Replace provider-specific request decisions with adapter descriptors:
- `openai-completions`
- `anthropic-messages`
- `ollama-generate`
- future `openai-responses`
- future `gemini-generate-content`
- Each adapter owns:
- path
- auth header format
- request body transform
- response text extraction
- reasoning/thinking block extraction
- models endpoint strategy
- `ProviderService.analyze()` should select adapter by resolved transport and call the adapter.
### Phase 5: Admin Next UX
- Model provider page should show:
- provider status tag
- default model tag
- source tag: env / runtime / preset / discovered
- model list with transport/capability hint
- separate buttons for save, set default, refresh model catalog, lightweight test
- The connect plug button remains lightweight.
- Full generation test lives only in Playground or a clearly named “试运行” action.
- If lightweight test falls back to preset confirmation, toast must say so explicitly.
## TODO
- [ ] Add `models_metadata` to provider presets and refresh responses.
- [ ] Add runtime resolver helper for provider/model transport selection.
- [ ] Remove any remaining provider/model-specific literals from `aiprovider` runtime routing.
- [ ] Add tests that `opencode-go/minimax-m2.7` resolves through metadata, not through runtime hardcode.
- [ ] Add tests for lightweight connectivity:
- [ ] 401 / 403 fail as auth error.
- [ ] 404 with known preset model passes as preset-confirmed.
- [ ] `/models` missing alias passes only when preset contains the alias.
- [ ] unknown model fails.
- [ ] Add discovery descriptors for OpenCode Go, OpenAI-compatible, Anthropic-compatible, Ollama, OpenRouter / One API.
- [ ] Add incremental model catalog merge semantics with stale marking.
- [ ] Update Admin Next model list to show model source, transport and capability.
- [ ] Keep save / set default / lightweight test / full test as separate actions.
- [ ] Document the final provider catalog schema in technical docs after implementation.
## Current Acceptance Criteria
- No runtime routing branch may depend on concrete model names like `minimax-m2.7`.
- OpenCode Go model refresh must not use the ordinary Zen free-model endpoint.
- Lightweight connect must not call `analyze()` or consume generation quota.
- Saving a provider must not automatically set it as default.
- Provider UI must distinguish configured key, fallback key, preset model and live-discovered model.
## Related Files
- `backend/app/services/llm_provider_catalog.py`
- `backend/app/api/v1/settings.py`
- `backend/app/services/ai_client.py`
- `aiprovider/main.py`
- `aiprovider/provider_service.py`
- `frontend/src/admin-next/pages/PlainResourcePages.tsx`
- `docs/plans/admin-next-parity-audit-closeout-plan.md`

View File

@@ -57,7 +57,7 @@
抽自现 manual.md重新组织
1. 首次启动 — `./planet.sh start`、默认账号(`admin/admin123``linkong/12345678`,引用 `b15d097b` 引入的 `DEFAULT_LOGIN_USERS`
1. 首次启动 — `./planet.sh start`、默认账号(`admin/admin123``linkong/LK12345678`,引用 `b15d097b` 引入的 `DEFAULT_LOGIN_USERS`
2. 启停与按模块重启 — `start/stop/restart``-b -f -a -d`
3. 健康检查 — `./planet.sh health`
4. 日志 — `./planet.sh log``-f -b -a`,日志文件路径

View File

@@ -0,0 +1,71 @@
# Earth 图层 Redis 缓存与 OOM 防护完整计划
## Summary
目标不是单纯“加缓存”,而是把 Earth 图层读路径改成可控、可观测、可降级的缓存架构,避免演示前高并发、重图层、船只数据膨胀再次把后端打到 OOM 无限重启。
前端继续请求原 APIresponse body 保持兼容。后端新增 Redis 读穿缓存、防击穿锁、stale 兜底、payload budget、主动失效、观测 header 和日志。
更新架构图:
```mermaid
flowchart TD
Earth["Earth 前端<br/>原 API 不变"] --> API["FastAPI Visualization / Layers APIs"]
API --> Guard["Request Guard<br/>limit clamp / bbox required / payload budget"]
Guard --> Cache["Layer Cache Adapter<br/>key / TTL / lock / stale"]
Cache -->|fresh hit| Redis["Redis<br/>earth:layer:v1:*<br/>fresh + stale payloads"]
Cache -->|miss or refresh| Builder["Layer Builders<br/>DB query + GeoJSON conversion"]
Builder --> DB["PostgreSQL / Timescale<br/>authoritative data"]
Builder --> Budget["Response Budget Check<br/>feature cap / byte cap / diagnostics"]
Budget --> Cache
Cache --> API
API --> Earth
Collectors["Collectors / Data writes"] --> DB
Collectors --> Invalidate["Source-scoped invalidation"]
Invalidate --> Redis
Cache --> Metrics["Structured logs / headers<br/>hit miss stale bypass refresh<br/>bytes features duration"]
```
## Implementation Changes
- Add an Earth layer cache adapter that owns Redis keys, TTLs, stale fallback, single-flight locks, JSON serialization, response headers, and graceful Redis bypass.
- Use `earth:layer:v1:{layer}:{params}` for fresh cache, `earth:layer:v1:{layer}:{params}:stale` for stale fallback, and `earth:layer:lock:v1:{hash}` for rebuild locks.
- Cache policy:
- `cables`, `landing-points`: fresh `6h`, stale `24h`
- `satellites`: fresh `15m`, stale `2h`
- `compute-centers`: fresh `10m`, stale `1h`
- `bgp-collectors`, `bgp-anomalies`, `bgp-incidents`, `geo/summary`: fresh `30-60s`, stale `10m`
- `vessels snapshot`: fresh `5s`, stale `30s`, with bbox rounded to `0.1` degrees and key including `zoom/type/limit/since_minutes`
- Prevent cache stampedes with `SET NX EX` locks. The lock holder refreshes; other requests prefer stale, wait briefly, then fall back to the guarded DB path.
- Enforce payload budgets on every cached layer: maximum features, maximum serialized bytes, and diagnostics when truncation happens.
- Keep vessel snapshot viewport-first: require bbox, clamp low-zoom limits, never build an unbounded all-vessel GeoJSON for Earth startup.
- Add cache observability headers: `X-Planet-Cache`, `X-Planet-Cache-Features`, `X-Planet-Cache-Bytes`, and development-only `X-Planet-Cache-Key`.
- Add super-admin system endpoints for Earth layer cache status and clearing.
## Public Interfaces
- Frontend request URLs stay unchanged.
- Response bodies stay compatible.
- New optional response headers report cache state.
- New system endpoints:
- `GET /api/v1/system/cache/earth-layers`
- `DELETE /api/v1/system/cache/earth-layers`
- Redis key contract: `earth:layer:v1:*`. Existing news keys remain `earth_news:target_location:*`.
## Test Plan
- Unit-test key generation, bbox rounding, payload budget truncation, Redis miss/hit, stale fallback, Redis bypass, and single-flight lock behavior.
- API-test repeated requests for cache headers, super-admin cache status/clear endpoints, and vessel snapshot bbox/limit safeguards.
- Regression-test existing layer guard behavior and vessel type forwarding.
- Verify Redis outage does not break Earth API responses.
- Verify large vessel requests return bounded payload diagnostics instead of exhausting memory.
## Assumptions
- PostgreSQL remains the authoritative data source; Redis is disposable read-through cache.
- First phase does not change frontend rendering. If Three.js rendering becomes the bottleneck, that is a separate frontend performance task.
- When safety conflicts with completeness, vessel responses prefer bounded/truncated data plus diagnostics over risking backend OOM.
- GeoJSON schema changes should bump the Redis key version from `v1` to `v2`.

View File

@@ -0,0 +1,349 @@
# 统一集成配置 Schema 系统计划
Last updated: 2026-05-20
## Summary
Planet 的采集器、AI Provider 和工具调用配置需要从“页面各自硬编码字段”收敛到同一套低代码 schema 系统。系统负责两件事:
- 用后台可编辑 schema 生成配置表单。
- 按字段 target 把表单值组装成后端运行时需要的请求、凭证和 JSON 配置。
这套 schema 不替代 `target_schema_registry``target_schema_registry` 继续负责采集结果映射和校验;本计划中的 `integration_config_schemas` 负责“怎么配置一个集成”。
## Current Problems
- Admin Next 的采集器配置曾把不同凭证形态压成通用 `api_key`,导致 `barentswatch_vessels` 这种 OAuth client credentials 丢失 `client_id` 字段。
- AI Provider、Web Search、OCR 和 DataSource 配置各自维护表单字段、secret 处理和 payload 组装逻辑,重复且容易漂移。
- 新增字段时需要改前端字段列表和保存逻辑,无法做到后台配置化扩展。
## Target Model
新增统一 registry`integration_config_schemas`,存储在 `SystemSetting.payload`
Registry 包含:
- `fragments`:可复用字段片段,例如 endpoint、API Key、OAuth Client、HTTP 请求、WebSocket 订阅、AI Provider 基础字段、工具超时字段。
- `auth_schemas`:可复用认证编排,例如 API Key、Bearer Token、Basic、OAuth2 Client Credentials、OAuth2 Authorization Code、Session Cookie Login。
- `schemas`:具体配置对象使用的 schema例如 `datasource:barentswatch_vessels``ai_provider:minimax``tool:web_search:tavily`
- `defaults`:每类集成的默认 schema例如 `datasource``ai_provider``tool`
Schema 必须声明 `kind`
- `datasource`
- `ai_provider`
- `tool`
字段定义统一使用:
```json
{
"key": "client_secret",
"label": "Client Secret",
"type": "secret",
"secret": true,
"target": "auth_config.client_secret",
"required": true,
"wide": true
}
```
字段 target 支持写入:
- DataSource`root.*``auth_config.*``headers.*``config.*`
- AI Provider`ai_provider.*``ai_provider.providers.{provider}.*`
- Tool`web_search.*``web_search.providers.{provider}.*``ocr.*`
## Auth Schema Model
认证必须成为 schema 系统的一等能力,不能再把所有凭证强行压成 `api_key`。每个配置 schema 可以引用一个 `auth_schema`,也可以内联声明认证编排。
Auth schema 描述:
- 凭证字段:哪些字段是 secret、是否必填、写入哪个 target。
- 凭证来源DB、env fallback、运行时草稿、用户授权回调。
- 预认证请求例如登录接口、token endpoint、OAuth callback。
- 凭证注入方式header、query、form、JSON body、cookie jar、WebSocket subscription payload。
- reveal 策略:管理员可 reveal 并写 audit log无 DB/env 值时显示空。
- 测试策略:连接测试必须使用当前表单草稿优先,再 fallback 到已保存/env。
v1 需要支持的认证类型:
- `none`:无认证。
- `api_key`API Key 写入 header/query/form/body。
- `bearer_token`Bearer token header。
- `basic`username/password支持直接 Basic header 或 provider 特定登录。
- `oauth2_client_credentials`client_id/client_secret 换 access_token。
- `oauth2_authorization_code`:第三方登录授权,包含 authorize URL、callback、token exchange、refresh。
- `session_cookie_login`:用户名密码登录后保存 cookie jar再访问数据接口。
- `custom_auth_preflight`:无法归类时,用声明式 preflight 请求生成后续请求上下文。
Auth schema 示例:
```json
{
"key": "spacetrack_session",
"type": "session_cookie_login",
"fields": [
{
"key": "username",
"label": "Username",
"target": "auth_config.username",
"required": true
},
{
"key": "password",
"label": "Password",
"type": "secret",
"secret": true,
"target": "auth_config.password",
"required": true
}
],
"preflight": {
"method": "POST",
"url": "https://www.space-track.org/ajaxauth/login",
"body_type": "form",
"body": {
"identity": "{{auth_config.username}}",
"password": "{{auth_config.password}}"
},
"success": {
"type": "cookie"
}
},
"inject": {
"type": "cookie_jar"
}
}
```
OAuth Authorization Code 示例:
```json
{
"key": "github_oauth",
"type": "oauth2_authorization_code",
"fields": [
{ "key": "client_id", "target": "auth_config.client_id", "required": true },
{ "key": "client_secret", "type": "secret", "secret": true, "target": "auth_config.client_secret", "required": true },
{ "key": "scopes", "type": "tags", "target": "auth_config.scopes" }
],
"authorization": {
"url": "https://github.com/login/oauth/authorize",
"client_id": "{{auth_config.client_id}}",
"scopes": "{{auth_config.scopes}}",
"redirect_uri": "{{system.callback_base_url}}/api/v1/integrations/oauth/github/callback"
},
"token": {
"method": "POST",
"url": "https://github.com/login/oauth/access_token",
"body_type": "form",
"body": {
"client_id": "{{auth_config.client_id}}",
"client_secret": "{{auth_config.client_secret}}",
"code": "{{oauth.code}}",
"redirect_uri": "{{oauth.redirect_uri}}"
}
},
"inject": {
"type": "bearer_header",
"token_path": "access_token"
}
}
```
## Request And Runtime Assembly
这套系统本质是面向 Planet 集成的低代码 Postman但目标不是临时发请求而是沉淀成可保存、可测试、可调度、可审计的运行配置。
Schema 支持描述:
- HTTP method`GET``POST`
- endpoint
- headers
- query params
- JSON body / form body
- auth schema / auth config / preflight auth flow
- WebSocket endpoint 和 subscription payload
- AI Provider 的 `provider_api``base_url``model``api_key``service_token`
- Tool 的 provider、base_url、api_key、timeout 和工具专属参数
请求执行顺序:
1. 从 schema 字段 target 组装 root/auth_config/headers/config。
2. 如果存在 auth schema先解析凭证来源和草稿覆盖。
3. 需要 preflight 时执行认证请求,例如 token exchange 或 login。
4. 把认证结果注入正式请求,例如 bearer header、cookie jar、query token。
5. 执行连接测试、采样、采集、AI Provider connect 或 tool connect。
## Default Schemas
### DataSource
默认采集器:
- endpoint
- method
- headers JSON
- query/body/config JSON
- API Key auth
- advanced JSON
`barentswatch_vessels`
- endpoint
- Client ID -> `auth_config.client_id`
- Client Secret -> `auth_config.client_secret`
- fixed `auth_type = oauth_client`
`aisstream_vessels`
- WebSocket endpoint
- API Key -> `auth_config.api_key`
- subscription / bounding boxes config
`spacetrack_tle`
- API Base URL / endpoint
- Username -> `auth_config.username`
- Password -> `auth_config.password`
- auth schema = `session_cookie_login`
- login endpoint = `https://www.space-track.org/ajaxauth/login`
- login body fields`identity` / `password`
- run/test request uses returned session cookie
### AI Provider
默认字段:
- provider
- provider_api
- base_url
- model
- api_key
- max_tokens
- anthropic_version
- service_url
- service_token
- timeout_seconds
- retry_attempts
- model_provider_apis
Provider presets supply initial defaults, but the editable schema controls which fields appear and where values are saved.
AI Provider auth variants
- OpenAI-compatible providers`api_key` or `bearer_token`
- Local/sidecar services`none``service_token` or custom header。
- OAuth-backed providers`oauth2_authorization_code`,适用于需要用户授权登录的 provider。
- CLI/session-backed tools such as Codex优先作为 `tool` 或本机 runner 集成;如果作为 provider必须显式声明会话来源、权限边界、不可多用户复用的限制。
### Tool
`web_search`
- enabled
- provider / default_provider
- base_url
- api_key
- max_results
- timeout_seconds
- endpoint_path
- search_depth
- engine
- include_answer / include_raw_content / include_text
- search_path / scrape_path / scrape_formats
`ocr`
- enabled
- provider
- base_url
- api_key
- model
- languages
- timeout_seconds
- max_file_size_mb
- output_format
Tool auth variants
- GitHub PAT`bearer_token`
- GitHub OAuth App`oauth2_authorization_code`,适合用户授权登录和代表用户访问。
- GitHub App`app_installation`,需要 app id/private key/installation id并通过 schema 声明 installation token exchange。
- Browser/session tools必须显式标记为 `session_local_only`,不能作为后台多用户稳定凭证。
## API Plan
- `GET /api/v1/integration-config-schemas`
- Return the full registry.
- `PUT /api/v1/integration-config-schemas`
- Save the registry. Admin only.
- `POST /api/v1/integration-config-schemas/validate`
- Validate full registry or one schema.
- `POST /api/v1/integration-config-schemas/auth/test`
- Test auth schema with draft credentials without saving.
- `GET /api/v1/integration-config-schemas/auth/secrets`
- Reveal stored/env-backed secret fields for admins; write audit log.
- `POST /api/v1/integrations/oauth/{provider}/start`
- Start OAuth Authorization Code flow.
- `GET /api/v1/integrations/oauth/{provider}/callback`
- Complete OAuth callback and store token material according to schema.
- `GET /api/v1/datasources/configs/all`
- Add `form_schema` to each row.
- `GET /api/v1/settings/integrations`
- Add `form_schema` for AI Provider and tools.
Existing save APIs remain compatible:
- DataSource saves to `DataSourceConfig`.
- AI Provider, Web Search and OCR save to `external_integrations`.
Secret fields never return plaintext through list/config endpoints. They return configured state and masked preview only.
Secret reveal endpoints return plaintext only on explicit administrator action and must log target, actor, source, result and timestamp. List/config endpoints must never leak secret plaintext.
## Frontend Plan
Admin Next extracts a reusable `SchemaForm`:
- Render fields from `form_schema.fields`.
- Build payload by writing values to each field `target`.
- Preserve masked secret semantics: unchanged masked values do not overwrite stored secrets.
- Reveal secrets through a schema-aware reveal action; if neither DB nor env has a value, show an empty editable input.
- Support text, secret, number, boolean, select, textarea, JSON and tags controls.
- Support auth controls for API key, username/password, OAuth connect/disconnect, session cookie login and custom preflight status.
- Validate schema before saving registry changes.
Pages migrated in v1:
- Collection Management / collector configs.
- AI / Provider configuration.
- AI / Tools configuration for Web Search and OCR.
Each detail page gets a schema editing action for admins. The editor saves registry JSON after validation.
## Test Plan
- Default registry initializes with datasource, AI Provider and tool schemas.
- Validation rejects duplicate keys, illegal targets, illegal field types and secret plaintext defaults.
- `barentswatch_vessels` renders and saves `client_id` / `client_secret`.
- `spacetrack_tle` renders username/password, tests with draft credentials, and does not require API Key.
- `aisstream_vessels` renders API Key and WebSocket subscription fields.
- Session cookie login auth executes preflight before sample/run and uses the resulting cookie jar.
- OAuth Authorization Code schema can start callback flow, store token metadata and inject bearer token.
- Secret reveal returns DB value, env fallback or empty value according to source, and writes audit log.
- Connection tests always prefer current draft credentials over saved/env credentials.
- AI Provider renders and saves `provider_api`, `base_url`, `model`, `api_key` and `service_token`.
- AI Provider and tool schemas can reuse the same auth schema primitives as DataSource.
- Web Search and OCR render and save provider-specific tool fields.
- Adding a schema field in the registry makes it appear in Admin Next without frontend code changes.
- Existing connection tests, datasource sampling, datasource run, AI Provider connect/reveal/refresh, and Web Search connect keep working.
## Assumptions
- `target_schema_registry` remains separate because it describes collected result shape, not configuration forms.
- The first implementation stores schema registry in `SystemSetting`; no new database table is required.
- Old AntD Settings pages stay compatible but are not migrated in v1.
- Tool scope in v1 is Web Search and OCR.

View File

@@ -30,8 +30,10 @@ What belongs here:
- [Collector Settings and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md): Data source catalog, collector settings, connectivity validation, and BarentsWatch credentials
- [Shared Location Resolution Pipeline Development Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-development.md): Backend location resolver / pipeline interfaces, registries, and extension points
- [Docs Gatekeeper Development Guide](/home/ray/dev/linkong/planet/docs/technical/en/docs-gatekeeper-development.md): Backend Docs catalog, Markdown content loading, and Gatekeeper permission groups
- [Naming Glossary](/home/ray/dev/linkong/planet/docs/technical/en/naming-glossary.md): English/Chinese term mapping for the console, Earth, backend, and docs
- [Earth Interactable Usage](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-usage.md): API, lifecycle, and integration examples for Earth surface icon Interactable
- [Earth Toolbar and Overlay Coordination](/home/ray/dev/linkong/planet/docs/technical/en/earth-toolbar-overlay-coordination.md): Closing matrix and integration rules for toolbar buttons, search, settings, news, and layer overlays
- [Tactile UI Components](/home/ray/dev/linkong/planet/docs/technical/en/tactile-ui-components.md): Portable button, switch, tooltip, and scrollbar APIs, theme tokens, and migration rules
What does not belong here:

View File

@@ -101,12 +101,31 @@ The AI settings page uses:
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.
Admin Next keeps the AI page aligned with the legacy information architecture:
- `Model Providers`
- Manages provider, wire adapter, default model, LLM API key, proxy URL, proxy token, model refresh, set-as-default, and lightweight connectivity testing.
- `Tool Calling`
- Manages tools such as WebSearch and OCR. Each tool first selects a provider, then edits that provider's API, key, and advanced parameters.
- `Prompts`
- Edits system/user prompts by prompt group and task key. Save and reset only affect the current task.
- `Playground`
- Runs real conversations with the active provider and prompt configuration. AI responses are rendered as Markdown.
Save, set-as-default, and connectivity testing are separate responsibilities: save only persists the form, set-as-default only changes the active provider/tool, and connectivity testing only validates the current draft. It must not implicitly save or switch defaults.
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:
@@ -253,6 +272,34 @@ Each provider has its own key slot. Resolution order is:
`.env` is only a fallback. After the settings page saves successfully, or after the connection test succeeds, PostgreSQL becomes the global default source.
Admin Next must compute key status per provider or tool:
- If the database has a key for the current provider/tool, show `configured`.
- If the database has no key but the fallback provider, model, or tool matches the current item, show the fallback masked preview.
- If neither database nor matching fallback exists, show `not configured`; a generic `.env` key for another provider must not make this item appear configured.
- Masking keeps the prefix before the first `-`, for example `sk-********`. Plaintext reveal is only available inside the authorized configuration page.
Tool keys follow the same rule. WebSearch and OCR must match the current tool and provider before they can use fallback credentials.
### Lightweight Connectivity Testing
The Admin Next plug button performs a lightweight connectivity check and does not save configuration. Common API-platform practice is two-tiered:
- Check a provider catalog or low-cost endpoint to validate base URL, authentication, and model reachability.
- Send full model requests only when the user explicitly runs Playground or a business task.
Connectivity results should be explicit:
- `ok`: authentication, route, and model catalog are usable.
- `warning`: service is reachable, but the current model is missing from the catalog or capability metadata is incomplete.
- `error`: authentication, network, protocol, or model lookup failed.
Toast titles must match the result; failures must not be titled as a successful connection.
### OpenCode Go Routing Model
Subscription channels such as OpenCode Go should not be handled by hard-coded frontend model sets. Prefer provider catalog or backend capability discovery that records per-model capabilities such as `chat_completions`, `anthropic_messages`, `models_endpoint`, and whether a subscription key is required. The frontend should display capabilities; the backend should map provider, base URL, model, and adapter into the real request.
#### Settings Page Behavior
- The Provider select controls the global default provider.

View File

@@ -93,6 +93,16 @@ Earth boundaries are no longer data collectors. They are Earth static rendering
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.
Admin Next collection management follows the business hierarchy instead of flattening every endpoint into one table:
- `Collectors`: endpoint, authentication, headers, base parameters, enabled state, and credential guides.
- `Collection Schedule`: scheduler state and task controls.
- `Collection History / Snapshots`: history grouped by collector, with a detail-side snapshot selector for versions.
Snapshot lists should not show every snapshot of the same collector as separate top-level records. The top-level list selects a collector; the detail area switches between time versions.
Credential guides are maintained by `backend/app/services/credential_guides.py`. The console uses read / generate / reset actions to load or create Markdown instructions. The frontend should render the guide Markdown for operators, not expose generation prompts or raw metadata.
## IV. Data Format (stored in CollectedData table)
```python

View File

@@ -32,8 +32,8 @@ If endpoint, headers, base configuration, or credential fingerprint changes afte
Files:
- [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx)
- [index.css](/home/ray/dev/linkong/planet/frontend/src/index.css)
- [PlainResourcePages.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/pages/PlainResourcePages.tsx)
- [AdminNextRoutes.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/AdminNextRoutes.tsx)
Current behavior:
@@ -53,30 +53,35 @@ 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.
### Collectors
### Collection Management
File:
- [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx)
- [PlainResourcePages.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/pages/PlainResourcePages.tsx)
Current behavior:
- 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:
- `Credentials required` / `No credentials required`
- Module
- `Enabled` / `Disabled`
- `Unchecked` / `Available` / `Unavailable`
- Whether the endpoint is overridden
- Collectors that require credentials place the credential card above base configuration.
- Collectors without credentials only show base configuration.
- `/collection-management` follows the legacy hierarchy: `Collectors`, `Collection Schedule`, and `Collection History / Snapshots`.
- `Collectors` is the configuration page. The left list shows collector configs; the right form edits endpoint, authentication, headers, collection parameters, and enabled state.
- New collectors and target schemas use draft detail pages instead of transparent JSON modals; save persists the draft, while cancel destroys it.
- The connection button only tests connectivity and does not save. The save button only persists the form.
- Credential guides open in a draggable Markdown modal. When no guide exists, the modal still opens and offers a generate action. Generation shows a centered waiting state; reset confirmation must render above the guide modal.
- `Collection History / Snapshots` groups by collector. The list must not repeat every snapshot for the same datasource; detail view switches versions through a Time Capsule / Time Machine style selector.
- The AISStream collector uses WebSocket semantics: connecting, streaming, reconnecting, or stopped. It does not use a fixed completion percentage.
- Custom source editing lives in collector settings. The data source catalog keeps overview, run controls, and read-only drawers.
The connection button uses an inline Tabler-style plug icon with `plug-connected` semantics, avoiding the older refresh icon for a connection action.
### Mapping Templates and Target Schemas
Mapping remains part of collection management, but not the main collector tab:
- `Mapping Templates` owns sample payload, AI propose, preview, create/update, and activate.
- `Target Schemas` maintains writable target structures.
- `run-mapped`, `stop-mapped`, and `stream-status` run mapped custom collectors.
The new UI should keep these paths form-first. Only advanced fields should collapse into JSON. Do not flatten templates, schemas, runtime status, and collector configuration into one table.
## Backend APIs
### Data Source Configuration List
@@ -140,6 +145,26 @@ Successful responses include:
- `credential_provider`
- `credential_source`
### Credential Guides
```http
GET /api/v1/datasources/credential-guides/{provider}
POST /api/v1/datasources/credential-guides/{provider}/generate
POST /api/v1/datasources/credential-guides/{provider}/reset
```
Purpose:
- Read the Markdown credential guide for a provider.
- Generate a guide from collector metadata when none exists.
- Reset back to the backend default guide.
Frontend rules:
- Render Markdown, not backend prompts or metadata.
- Generate/reset actions belong inside the guide modal, not in the collector configuration toolbar.
- Missing guides still open a modal so the user can generate one from there.
### BarentsWatch AIS Connectivity Validation
```http

View File

@@ -66,10 +66,10 @@ DocsMetadata(
)
```
When adding a public technical doc:
When adding a technical doc that should appear in the Docs page:
- Add both Chinese and English Markdown files.
- Add filename, slug, access, group, order, and titles to server `DOCS_METADATA`.
- Add filename, slug, access, group, order, and titles to server `DOCS_METADATA`. The backend catalog endpoint is authoritative; frontend metadata alone does not publish a document into `/docs` navigation.
- Add matching metadata to frontend [docs-content.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-content.ts) so navigation titles and sorting stay aligned.
- Update `docs/technical/zh/README.md` and `docs/technical/en/README.md` when the document should be discoverable from the README.

View File

@@ -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)
@@ -145,6 +149,22 @@ 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.
`about.js` manages the About card inside Earth settings. Frontend defaults remain as a fallback, while runtime content is loaded from `/api/v1/earth/about`. If the request fails or fields are missing, the renderer must fall back per field so the settings page never renders an empty card. Admin Next exposes an Earth Content `About` tab; saving uses `PUT /api/v1/earth/about`, and restoring defaults uses `DELETE /api/v1/earth/about`.
`oobe.js` manages the first-run Earth initialization guide. OOBE visibility must be driven by `/api/v1/earth/oobe-status` and its `ready` field, not by `localStorage`. `localStorage` may only store a short-lived "skip on this browser" flag; if the backend reports `ready: true`, logout, cleared browser storage, or a different browser must not show OOBE again. Desktop uses a dark starfield scrim and glass startup panel, while mobile uses a bottom sheet and respects `prefers-reduced-motion`.
The Admin Next Earth Content page must preserve runtime semantics:
- `Brand`: brand preview should use the same dark starfield background, size, spacing, logo/title rendering, and text fallback as the Earth HUD top-left brand block, not a generic form preview.
- `About`: configures the About card in Earth settings, including logo, kicker, title, version, description, and metadata items. Earth runtime reads `/earth/about` and falls back to defaults on failure.
- `Boundary Precision`: build boundary, refresh status, and restore defaults belong inside this section, not in the global page toolbar.
- `TV`: the list distinguishes built-in, collected, and custom sources. Card state represents enabled, disabled, draft, or error. Built-in sources cannot be deleted; collected and custom sources can. A new live source only enters draft state after the plus button is clicked; save persists it into the list, while cancel destroys the draft.
- `Basemap`, `Layer Resources`, `3D Models`, and `News Anchor Strategy`: if backend capability is not available yet, the console should show an explicit pending state instead of mixing those items into TV or brand configuration.
TV preview should reuse the Earth runtime live-card structure and state labels as closely as possible so built-in markers, live loading state, stream source, region, and language match what users see on Earth.
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.

View File

@@ -340,6 +340,12 @@ Collecting and saving are two separate actions. Candidates can be previewed on E
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.

View File

@@ -22,7 +22,7 @@ Main entry point:
- [App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx)
Current admin-related routes:
Admin Next now owns the official admin routes:
- `/admin`
- `/users`
@@ -37,15 +37,45 @@ Current admin-related routes:
- `/collection-management`
- `/settings`
These routes render [AdminNextRoutes.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/AdminNextRoutes.tsx). Page metadata and menu entries come from [manifest.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/routes/manifest.tsx). `/admin-next/*` remains only as a compatibility entry and redirects to the official route; it is no longer a parallel primary entry.
The old AntD console remains available under `/legacy/admin/*` for comparison and rollback:
- `/legacy/admin`
- `/legacy/admin/datasources`
- `/legacy/admin/data`
- `/legacy/admin/collection-management`
- `/legacy/admin/earth-content`
- `/legacy/admin/ai`
- `/legacy/admin/logs`
- `/legacy/admin/settings`
- `/legacy/admin/users`
- `/legacy/admin/bgp`
- `/legacy/admin/alerts/*`
Legacy pages, `AppLayout`, `antd`, and `@ant-design/icons` stay in place during the legacy validation window. Do not remove them before Admin Next parity is accepted.
`/earth` is a standalone display page and is not part of the console shell.
## Current Page Shell
The console shared shell is at:
The official admin shell is at:
- [AdminNextLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/components/layout/AdminNextLayout.tsx)
Responsibilities:
- Left navigation, grouped collapse, and mobile drawer
- Current account, version, logout, and theme switching
- Top search, breadcrumbs, and page shortcuts
- Single-screen content-area height closure
- Coordination for Admin Next internal scrolling, tables, detail panels, and mobile detail views
The old AntD legacy shell remains at:
- [AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx)
Responsibilities:
Legacy responsibilities:
- Left-side navigation
- Collapse and expand
@@ -66,7 +96,40 @@ Current structure:
</Layout>
```
Future console pages should adapt to this shell rather than redefining full-page height semantics.
Future official admin pages should adapt to `AdminNextLayout` and Admin Next page patterns rather than adding new capability to the old `AppLayout`. Only `/legacy/admin/*` maintenance should change the old shell.
## Admin Next Section Loading
Multi-tab pages are currently coordinated by [PlainResourcePages.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/pages/PlainResourcePages.tsx), which hosts the current management and information workbench patterns. Section loading follows these rules:
- Initial page load requests only the active tab; it does not prefetch every tab endpoint.
- Switching tabs lazily loads that tab. Loaded tabs stay cached in local `states`, so returning to a tab reuses the previous data.
- Explicit actions such as refresh, save, test, upload, and credential-guide generation refresh only the current section instead of fanning out to unrelated sections.
- Datasource directory filter changes clear the built-in-source section cache and reload the current section with the new filters.
- Top summary metrics count loaded sections only, so unopened tabs are not reported as failed interfaces.
This keeps Earth, AI, collection management, and other multi-section pages from flooding backend APIs on cold start while preserving a fast cached tab-switching experience. Full health checks should use backend health endpoints or explicit refresh flows rather than relying on page initialization to touch every business endpoint.
## Datasource Collection Queue
The Admin Next datasource page routes single-source trigger, table-selected trigger, and trigger-all into a browser-download-list style collection queue:
- Queue state is managed by [PlainResourcePages.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/pages/PlainResourcePages.tsx). It is a current-session visibility layer and does not fake task history in `localStorage`.
- Progress first consumes the `/ws` `datasource_tasks` channel. If the socket is unavailable or stale, the page polls `/api/v1/datasources/{id}/task-status`.
- Trigger responses immediately insert `triggered`, `skipped`, and `failed` items. After a refresh, the queue restores only real backend rows that are still `running`, `pending`, or `queued`.
- The `Built-in Sources` section uses the table selection column for selected-source triggering. With no rows selected, the primary button is `Trigger All`; after selection, the same button becomes `Trigger Selected N`, replacing the old manual-ID batch button.
- The expanded queue no longer lives in the page content flow, so trigger-all cannot squeeze the table and detail panel. The top-right actions area uses the existing `Button` styling; the empty state shows a `ListChecks` icon, and active queues show only a pure circular total-progress indicator. Clicking it opens a floating panel grouped by running, failed, completed, and skipped.
- Queue rows can jump to the datasource detail panel, and failed rows can retry. The detail panel's task summary only reports the selected source's latest task; it does not save configuration.
This queue is a user-perception layer. Backend task status remains the only source of truth for running, completion, failure, and skipped decisions.
## Admin Next Theme Slider
The Admin Next sidebar theme switcher still reuses shared [SegmentedControl.tsx](/home/ray/dev/linkong/planet/frontend/src/components/SegmentedControl/SegmentedControl.tsx), while [styles.css](/home/ray/dev/linkong/planet/frontend/src/admin-next/styles.css) overrides the segment variables by `data-theme`:
- Light mode uses `--d-segment-bg: #eef3f9`, a white slider, and a light external shadow.
- Dark mode uses the same dark base, `#202938` slider, and dark external shadow semantics as Docs.
- Product code only hides the text label and keeps icon + tooltip behavior; it should not recreate the private slider DOM.
## Current Shared Components
@@ -74,7 +137,8 @@ Future console pages should adapt to this shell rather than redefining full-page
File:
- [Scrollbar.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/Scrollbar.tsx)
- [Scrollbar.tsx](/home/ray/dev/linkong/planet/frontend/src/components/tactile-ui/Scrollbar.tsx)
- [Tactile UI Components](/home/ray/dev/linkong/planet/docs/technical/en/tactile-ui-components.md)
Purpose:
@@ -91,7 +155,7 @@ Current constraint:
File:
- [ScrollbarOverlay.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/ScrollbarOverlay.tsx)
- [ScrollbarOverlay.tsx](/home/ray/dev/linkong/planet/frontend/src/components/tactile-ui/ScrollbarOverlay.tsx)
Purpose:
@@ -100,25 +164,42 @@ Purpose:
Current usage:
- Data sources
- Collected data
- User management
- Settings page
- Alerts page
- BGP page
- Admin Next data sources, collected data, collection management, logs, alerts, and BGP pages
- Old AntD legacy pages continue using shared scrolling behavior through compatibility wrappers
### 3. `TableScrollRegion`
File:
- [TableScrollRegion.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/TableScrollRegion.tsx)
- [TableScrollRegion.tsx](/home/ray/dev/linkong/planet/frontend/src/components/tactile-ui/TableScrollRegion.tsx)
Purpose:
- Provides a unified wrapper for table scroll areas
- New table pages should reuse this rather than repeating the "table area + overlay scrollbar" boilerplate
### 4. `SegmentedControl`
### 4. `TactileButton` / `TactileSwitch` / `ControlGroup`
Files:
- [Button.tsx](/home/ray/dev/linkong/planet/frontend/src/components/tactile-ui/Button.tsx)
- [Switch.tsx](/home/ray/dev/linkong/planet/frontend/src/components/tactile-ui/Switch.tsx)
- [ControlGroup.tsx](/home/ray/dev/linkong/planet/frontend/src/components/tactile-ui/ControlGroup.tsx)
Purpose:
- Admin Next global tool buttons and detail-panel toolbars
- Icon-only ordinary actions with tooltips
- Strong-intent actions such as save, create, confirm, delete, and stop
- Compact switches aligned with the Docs theme slider
Current constraints:
- Neutral buttons default to a white tactile surface with external shadow
- Unambiguous actions prefer icon + tooltip; strong-intent actions like save may keep text
- Feature pages should customize through props and CSS variables, not by rewriting core button CSS
### 5. `SegmentedControl`
Files:
@@ -146,7 +227,7 @@ Current constraints:
- Prefer CSS variable overrides for colors instead of hard-coding theme colors in feature components
- Best for a small set of mutually exclusive choices; do not use it as a long list, navigation menu, or select replacement
### 5. `MarkdownRenderer`
### 6. `MarkdownRenderer`
File:
@@ -165,7 +246,7 @@ Current constraints:
- Internal document links should be converted to `/docs/:slug` through `transformLink`
- Heading anchors are injected through `getHeadingId`, keeping route state outside the renderer
### 6. `ConnectionTestInput`
### 7. `ConnectionTestInput`
File:
@@ -183,7 +264,7 @@ Current constraints:
- Disabled integrations must grey out both the input and its connection-test action
- The component only combines the input and action; callers still own form state, loading, disabled state, and the request itself
### 7. `TableActions`
### 8. `TableActions`
File:

View File

@@ -187,19 +187,23 @@ TV livestreams and boundary precision moved to `/earth-content`; collectors and
`/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.
- **About**: manages the About card shown in Earth settings, including logo, kicker, title, version, description, and metadata.
- **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.
If the backend decides Earth is not initialized yet, the first visit to `/earth` shows a glassy startup guide that points the user to sign in and collect data. This is based on backend data state; once the system has collected data, clearing browser storage does not make the guide reappear.
### 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.
- **Collection History / Snapshots**: groups snapshots by datasource and lets the detail panel switch versions through a Time Capsule selector.
### SMTP Email Settings
@@ -229,7 +233,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 `/collection-management -> Collectors`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list
- `/datasources`: source directory. The `Built-in Sources` tab can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. With no rows selected, the primary button shows `Trigger All`; after selecting rows, it becomes `Trigger Selected N` and submits only those sources. The top-right queue button shows a queue icon when empty and a pure circular total-progress indicator while tasks exist; it opens a floating panel grouped by running, completed, failed, and skipped. Failed rows can retry, and completed rows can jump to detail. `Realtime Sources` is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. Endpoint/credential/header editing happens at `/collection-management -> Collectors`.
- `/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
@@ -280,7 +284,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.
@@ -288,6 +292,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 |

View File

@@ -0,0 +1,99 @@
# Naming Glossary
This document standardizes terms used across the Planet console, Earth, backend APIs, and documentation. When adding Chinese UI labels, chart labels, or translated documentation, prefer the Chinese display names listed here to avoid unnecessary mixed Chinese/English copy.
## Usage Rules
- Chinese UI should use the Chinese display name.
- API fields, database fields, code identifiers, and external brand names keep their original English or snake_case form.
- On first mention in long-form docs, use “Chinese Display Name (English Term)” when clarification helps; subsequent mentions can use the Chinese display name.
- Industry abbreviations such as BGP, AI, API, URL, ID, TLE, ASN, GPU, and AIS may remain English.
- External service brands keep their official English names, such as PeeringDB, Space-Track, BarentsWatch, AISStream, and GitHub.
## Core Product Terms
| English / Key | Chinese Display Name | Usage |
| --- | --- | --- |
| Planet | Planet | Product name |
| Admin Next | 新控制台 | New admin console |
| Earth | Earth | Visualization product name |
| datasource | 数据源 | APIs, lists, filters |
| collector | 采集器 | Collection jobs and credential configuration |
| collected data | 采集数据 | Data list and statistics |
| snapshot | 快照 | Collection history |
| mapping | 映射 | Field mapping |
| target schema | 目标 Schema | Mapping target structure |
| credential | 凭证 | Secrets, account passwords, OAuth material |
| secret reveal | 密钥 reveal | Explicit administrator secret reveal action |
| AI Provider | AI Provider | Service name |
| tool | 工具 | Web Search, OCR, and similar integrations |
| Playground | Playground | Interactive debugging entry |
## Data Types
| data_type | Chinese Display Name | Meaning |
| --- | --- | --- |
| `supercomputer` | 超算 | TOP500 supercomputer record |
| `gpu_cluster` | GPU 集群 | Epoch AI GPU cluster |
| `model` | 模型 | Hugging Face model |
| `dataset` | 数据集 | Hugging Face dataset |
| `space` | 空间 | Hugging Face Space |
| `ixp` | 互联网交换点 | PeeringDB IXP |
| `network` | 网络 | PeeringDB network |
| `facility` | 设施 | PeeringDB facility |
| `submarine_cable` | 海底光缆 | Submarine cable route |
| `landing_point` | 登陆点 | Cable landing point |
| `cable_system` | 海缆系统 | TeleGeography cable system |
| `cable_landing_relation` | 海缆登陆关系 | Cable-to-landing-point relation |
| `satellite_tle` | 卫星轨道根数 | CelesTrak / Space-Track TLE |
| `bgp_rib` | BGP 路由表 | BGP RIB snapshot |
| `bgp_update` | BGP 更新 | BGP update event |
| `prefix_geography` | 前缀地理位置 | IP prefix geography |
| `vessel_ais` | AIS 船舶 | AIS vessel observation |
| `vessel` | 船舶 | Aggregated vessel object |
| `news_live_stream` | 新闻直播源 | News live stream source |
| `news_item` | 新闻条目 | Archived news item |
| `device_stats` | 设备统计 | Cloudflare Radar device statistics |
| `traffic_stats` | 流量统计 | Cloudflare Radar traffic statistics |
| `as_stats` | 自治系统统计 | Cloudflare Radar AS statistics |
| `compute_center` | 算力中心 | Aggregated Earth compute-center object |
| `generic` | 通用数据 | Generic collector output |
| `generic_records` | 通用记录 | Generic mapping output |
## Datasources And External Services
| English / Key | Chinese Display Name | Notes |
| --- | --- | --- |
| TOP500 | TOP500 超算榜单 | Keep TOP500 as the external list name |
| Epoch AI | Epoch AI GPU 集群 | Keep Epoch AI as the service name |
| Hugging Face | Hugging Face | Official brand name |
| PeeringDB IXP | PeeringDB 交换中心 | IXP can also be explained as 互联网交换点 |
| PeeringDB Networks | PeeringDB 网络 | Network entity |
| PeeringDB Facilities | PeeringDB 设施 | Facility entity |
| Submarine Cables | 海底光缆 | Prefer Chinese in charts and lists |
| Cable Landing Points | 海缆登陆点 | Use 登陆点 consistently |
| CelesTrak TLE | CelesTrak 轨道根数 | TLE may remain abbreviated |
| Space-Track TLE | Space-Track 轨道根数 | Keep Space-Track as brand name |
| RIPE RIS Live BGP | RIPE RIS Live BGP | Keep service name and BGP abbreviation |
| IPtoASN Prefix Geography | IPtoASN 前缀地理位置 | Keep IPtoASN as dataset name |
| OpenGeoFeed Prefix Geography | OpenGeoFeed 前缀地理位置 | Keep OpenGeoFeed as brand name |
| NRO Delegated Prefix Geography | NRO Delegated 前缀地理位置 | Keep NRO Delegated as dataset name |
| BarentsWatch AIS Vessels | BarentsWatch AIS 船舶 | Keep BarentsWatch / AIS |
| AISStream Vessels | AISStream 船舶 | Keep AISStream as brand name |
## UI Actions And Statuses
| English / Key | Chinese Display Name | Usage |
| --- | --- | --- |
| success | 成功 | Status badge |
| failed | 失败 | Status badge |
| running | 运行中 | Task status |
| completed | 已完成 | Task status |
| cancelled | 已取消 | Task status |
| enabled | 已启用 | Toggle state |
| disabled | 已停用 | Toggle state |
| configured | 已配置 | Credential / integration state |
| unconfigured | 未配置 | Credential / integration state |
| refresh | 刷新 | Button |
| copy | 复制 | Button |
| reveal | reveal | Keep English for audited secret reveal semantics |

View File

@@ -21,7 +21,7 @@ First startup seeds two default accounts (see `DEFAULT_LOGIN_USERS` in `backend/
| Username | Password | Role |
| --- | --- | --- |
| `admin` | `admin123` | `super_admin` |
| `linkong` | `12345678` | `super_admin` |
| `linkong` | `LK12345678` | `super_admin` |
Both seed accounts are created with `email_verified = TRUE` and can log into the console immediately. Any other account must either go through the public registration flow described in the Manual, or be created via `./planet.sh createuser`.
@@ -61,6 +61,22 @@ Per-module restart:
Per-module restart is preferred during development to avoid interrupting unrelated services.
## Destructive Reset
```bash
./planet.sh destroy
```
`destroy` returns a local development environment to a near-empty project state. It requires typing `Y` before it runs; source files and existing `.env` files are preserved.
Cleanup order and boundaries:
- If `planet_postgres` is running, the script first clears the `public` schema in `planet_db`. This prevents old `collected_data.is_current = true` rows from making Earth OOBE report `ready=true` if Docker volume removal later fails.
- Docker cleanup targets resources whose Compose project is `planet`, plus the explicit volumes `planet_postgres_data`, `planet_redis_data`, `postgres_data`, and `redis_data`; do not delete unlabeled volumes by a broad `planet_*` pattern, because another local project could own them.
- Local build state removes `.venv`, frontend `node_modules` / `dist`, Planet state/cache, and scattered Python / Vite cache directories.
After the reset, run `./planet.sh init` again to recreate tables and default seed data. Old collected records are not restored, and Earth OOBE is evaluated from the backend's real collection state on the next visit.
## Health Check
```bash
@@ -87,8 +103,8 @@ Recent logs:
Follow:
```bash
./planet.sh log -f # frontend: /tmp/planet_frontend.log
./planet.sh log -b # backend: /tmp/planet_backend.log
./planet.sh log -f # frontend: ~/.local/state/planet/frontend.log
./planet.sh log -b # backend: ~/.local/state/planet/backend.log
./planet.sh log -a # AI Provider: planet_aiprovider container logs
```
@@ -234,6 +250,20 @@ Validate the frontend build:
source ~/.zshrc && bun run build
```
Use `bun run dev` during development; Vite HMR refreshes the browser after source saves. `bun run build` only writes the `dist` artifact and does not refresh an already-open dev page.
To inspect the production bundle with automatic reload after successful builds:
```bash
bun run preview:auto
```
To watch sources and rebuild continuously without starting the preview server:
```bash
bun run build:watch
```
Backend dependencies are managed with uv:
```bash

View File

@@ -34,7 +34,7 @@ After landing on the `/admin` dashboard, here's a recommended walk-through:
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
3. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Built-in Sources` for finite collectors: with no rows selected, click `Trigger All`; after selecting rows, the primary button becomes `Trigger Selected N`. The top-right queue button shows progress. Use `/datasources -> Realtime Sources` for AISStream / WebSocket health and counters
4. `/alerts/system`: verify system alerts look right
5. `/users` (super_admin only): open accounts for teammates or adjust their groups
@@ -42,6 +42,8 @@ After landing on the `/admin` dashboard, here's a recommended walk-through:
Visit `/earth`. This is a public page — no login required.
If the system has no collected data yet, Earth shows an initialization guide that points you to sign in and trigger collection. This guide is backend-state driven, so clearing browser storage does not make it appear once the system is ready.
Once in, verify:
- The globe renders, and the right-side layer panel can toggle layers

View File

@@ -0,0 +1,223 @@
# Tactile UI Components
Tactile UI is Planet's portable React control layer. It was extracted from the Admin Next button, switch, scrollbar, and tooltip work, but the components themselves do not depend on Admin Next, AntD, Radix, Tailwind, or `an-*` classes. The immediate goal is stable in-repo usage; the structure is intentionally close to something that can later be published as an npm package.
## Design Goals
- **Light tactile feel**: controls default to a white or themed surface, thin borders, and external shadow, matching the subtle depth of the Docs theme slider rather than large colored blocks or glow.
- **Portable styling**: classes use the `tui-*` prefix and styles live in `frontend/src/components/tactile-ui/styles.css`.
- **Low dependency surface**: components assume React and React DOM. Preset icons currently use `lucide-react`, and callers may also pass custom React nodes.
- **Theme friendly**: CSS variables expose the default styling surface. Planet pages adapt the library through theme variables and props.
- **Clear semantics**: unambiguous actions should prefer icon-only buttons with tooltips; strong-intent actions such as save, confirm, create, and run can keep text.
## Import
In this repository:
```tsx
import { TactileButton, TactileSwitch, ControlGroup } from '@/components/tactile-ui'
import '@/components/tactile-ui/styles.css'
```
After a future package extraction:
```tsx
import { TactileButton, TactileSwitch } from '@planet/tactile-ui'
import '@planet/tactile-ui/styles.css'
```
## Theme Tokens
Core tokens are exposed as `--tui-*` CSS variables. Product themes should override variables instead of rewriting internal component classes.
```css
:root {
--tui-surface: #ffffff;
--tui-surface-raised-hover: #f8fbff;
--tui-border-soft: #d6dfeb;
--tui-border-hover: #b8c6d9;
--tui-text: #0f172a;
--tui-primary: #2563eb;
--tui-danger: #dc2626;
}
[data-theme='dark'] {
--tui-surface: #111827;
--tui-text: #e5edf8;
}
```
Most controls also accept a `tactile` prop for local width, height, radius, background, border, and shadow overrides. Use local overrides for small special cases; use CSS variables for product-wide styling.
## Portals and Dark Theme
`TactileTooltip` and the Admin Next `Dialog`, `Select`, and toast controls that use Tactile UI may render through a portal attached to `document.body`. Those nodes are not descendants of `.admin-next-theme-root[data-theme='dark']`, so dark tokens cannot rely only on an ancestor selector inside the Admin Next root.
The Admin Next theme provider mirrors the active theme to `body[data-admin-next-theme]`. Shared styles need to support both selector paths:
```css
[data-theme='dark'] .tui-button,
body[data-admin-next-theme='dark'] .tui-button {
--tui-surface: #172033;
--tui-text: #e5edf8;
}
```
When adding a portal-based control, first check whether it renders into body. If it does, add a `body[data-admin-next-theme='dark']` branch in that component's style entry, or reuse the already covered `--tui-*` / `--an-*` tokens. Avoid hard-coding a one-off dark modal style, because the same contrast problem can reappear in dropdowns, tooltips, toasts, and confirmation dialogs.
## `TactileButton`
The button component covers regular buttons, icon buttons, strong-intent buttons, and link-like buttons.
Common props:
| Prop | Description |
| --- | --- |
| `variant` | `neutral`, `primary`, `danger`, `subtle`, or `ghost` |
| `size` | `sm`, `md`, `lg`, or `icon` |
| `shape` | `square` or `pill` |
| `icon` | Preset icon name or a custom React node |
| `iconOnly` | Fixed-size icon button; provide `tooltip` or `aria-label` |
| `tooltip` | Rendered through a portal and offset away from the cursor |
| `loading` | Disables the button and exposes `aria-disabled` |
| `tactile` | Overrides size, radius, shadow, background, and dark-mode variables |
```tsx
<TactileButton icon="refresh" iconOnly tooltip="Refresh" />
<TactileButton variant="primary" icon="save">Save</TactileButton>
<TactileButton variant="danger" icon="delete" iconOnly tooltip="Delete" />
```
`variant="neutral"` defaults to a white tactile button. Colored buttons should still keep the same height and external shadow instead of relying on page-specific CSS overrides.
Colored button borders must not use the exact fill color. `primary`, `danger`, and future colored variants should use a lighter border from the same hue, such as `color-mix(in srgb, var(--tui-danger) 64%, white)`. The border still reads as part of the button color, but its visual weight is lower than the fill surface, so red or blue buttons do not look one outline larger than neutral buttons. Hover states should brighten rather than darken: mix a little white into the current fill color, and keep the hover border lighter than the hover fill.
## Icon Presets
Preset icons are maintained in `tactileIconPresets`. Feature pages should call icons by semantic name so actions remain consistent across the console.
Common semantics:
| Name | Use |
| --- | --- |
| `refresh` | Refresh data |
| `save` | Save |
| `delete` | Delete |
| `trigger` / `collect` | Trigger collection |
| `start` / `play` | Start |
| `stop` | Stop |
| `connect` / `test` | Connectivity check |
| `guide` | Credential guide |
| `generate` | AI generation |
| `reset` | Restore defaults |
| `detail` | View details |
| `copy` | Copy |
| `upload` | Upload |
Custom icons are also supported:
```tsx
<TactileButton icon={<MyIcon aria-hidden="true" />} iconOnly tooltip="Custom action" />
```
## `TactileSwitch`
`TactileSwitch` is for binary settings. It is not a large iOS-style switch; it matches the compact tactile slider used by Docs.
```tsx
<TactileSwitch
checked={enabled}
onCheckedChange={setEnabled}
label="Enable WebSearch"
tooltip="Enable WebSearch"
/>
```
The label defaults to tooltip content. If visible text is needed, render it in the surrounding layout rather than inside the switch.
## `ControlGroup`
`ControlGroup` arranges action buttons with consistent spacing and alignment. It has no grey base by default; use `withBase` or `baseTactile` only when a base surface is intentional.
```tsx
<ControlGroup align="end" gap={8}>
<TactileButton icon="refresh" iconOnly tooltip="Refresh" />
<TactileButton variant="primary" icon="save">Save</TactileButton>
</ControlGroup>
```
In list footers, the group should appear at the end of the scroll content, not float in the middle of the list. In detail toolbars, size and shadow should come from the shared component defaults.
## `TactileTooltip`
Tooltips render to `document.body` via `createPortal`, support `top`, `right`, `bottom`, and `left`, and perform simple collision correction. The default position is offset down and to the right from the trigger so it does not sit under the cursor.
```tsx
<TactileTooltip label="Refresh" anchor={buttonRef.current} open={open} side="bottom" />
```
Application code usually does not need to use it directly because `TactileButton` and `TactileSwitch` include tooltip handling.
## `Scrollbar`
`Scrollbar` wraps ordinary scrollable content. Native scrolling remains on the viewport; Tactile UI only renders the overlay track and thumb.
```tsx
<Scrollbar axis="both" minThumbSize={28} autoHide>
<LongContent />
</Scrollbar>
```
Common props:
| Prop | Description |
| --- | --- |
| `axis` | `x`, `y`, or `both` |
| `autoHide` | Fade/soften when not hovered or dragged |
| `alwaysVisible` | Keep scrollbars visible |
| `trackSize` / `thumbSize` | Track and thumb dimensions |
| `inset` / `radius` | Edge inset and radius |
| `thumbColor` / `trackColor` | Local color overrides |
| `viewportRef` | External real scroll node |
Do not force overlay scrollbars onto textareas. Text selection and the resize grip need native behavior. If a consistent textarea look is needed, use a dedicated resizable textarea style that preserves the native grip hit area.
## `ScrollbarOverlay`
`ScrollbarOverlay` is for areas that already own a scroll node, such as a third-party table or custom viewport. It does not create a new scroll container; it observes the target and paints overlay scrollbars.
```tsx
<div ref={hostRef}>
<ThirdPartyTable />
<ScrollbarOverlay containerRef={hostRef} targetSelector=".tui-scroll-target" />
</div>
```
## `TableScrollRegion`
`TableScrollRegion` is a convenience wrapper for table scroll areas. The default target selector is `.tui-scroll-target`; Admin Next passes its table viewport selector explicitly so package code does not contain product-specific names.
```tsx
<TableScrollRegion targetSelector=".table-viewport">
<EntityTable />
</TableScrollRegion>
```
## Accessibility
- `iconOnly` buttons must provide `tooltip` or `aria-label`.
- `TactileSwitch` uses `role="switch"` and `aria-checked`.
- Tooltip text is explanatory only; state must still be represented by text, badges, or `aria-*` attributes.
- Disabled and loading states set `aria-disabled`; real `button` elements also receive `disabled`.
## Admin Next Migration Rules
Admin Next should use Tactile UI for global tool buttons, detail-panel toolbars, and list footer actions:
- Unambiguous actions: `TactileButton iconOnly tooltip`
- Save/create/confirm: `TactileButton variant="primary"`, usually with text
- Delete/stop: `TactileButton variant="danger"`, with text only when risk or ambiguity requires it
- Switches: `TactileSwitch`
- Long lists, tables, logs, and Markdown: `Scrollbar` / `ScrollbarOverlay` / `TableScrollRegion`
The boundary is deliberate: Tactile UI owns touch feel, size, shadow, tooltip, and scrollbar behavior; feature pages own APIs, state machines, permissions, and copy.

View File

@@ -28,8 +28,10 @@
- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)数据源目录、采集器设置、连接验证、BarentsWatch 凭证链路
- [通用位置估算管线开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-development.md):后端 location resolver / pipeline 的接口、注册表和扩展方式
- [Docs Gatekeeper 开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/docs-gatekeeper-development.md):后端 Docs 目录、正文读取和 Gatekeeper 权限组实现
- [命名与术语对照](/home/ray/dev/linkong/planet/docs/technical/zh/naming-glossary.md)控制台、Earth、后端和文档常见名词的中英对照
- [Earth 可交互图标接入](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md)Earth 地表可交互图标 `Interactable` 的接口、生命周期和接入示例
- [Earth 工具栏与浮层协同](/home/ray/dev/linkong/planet/docs/technical/zh/earth-toolbar-overlay-coordination.md):工具栏按钮与搜索 / 设置 / 新闻 / 图层浮层之间的关闭矩阵和接入规则
- [Tactile UI 组件库](/home/ray/dev/linkong/planet/docs/technical/zh/tactile-ui-components.md)可移植按钮、开关、tooltip 和滚动条组件的 API、主题 token 与迁移约定
不适合放入这里的内容:

View File

@@ -101,12 +101,31 @@ AI 配置页使用的接口:
这些接口都需要用户登录。`secrets` 接口只用于配置页点击显示 key/token 时取回明文,隐藏时前端恢复为脱敏预览。
Admin Next 的 AI 页面按旧版信息架构组织为:
- `模型供应商`
- 管理 provider、协议适配、默认模型、LLM API Key、代理地址、代理 token、模型列表刷新、设为默认和轻量连通性测试。
- `工具调用`
- 管理 WebSearch、OCR 等工具。工具内部先选 provider再编辑该 provider 的 API、key 和高级参数。
- `提示词`
- 按 prompt 分组和 task key 编辑系统提示词 / 用户提示词;保存和重置只作用于当前 task。
- `Playground`
- 使用当前默认 provider 和 prompt 配置发起真实会话AI 回复使用 Markdown 渲染。
保存、设为默认和连通性测试是三个独立职责:保存只持久化表单,设为默认只切换默认 provider/tool连通性测试只验证当前草稿是否可用不应隐式保存或切换默认项。
`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
仅供内部调用的接口:
@@ -253,6 +272,34 @@ AI 配置仍保存在 PostgreSQL不写入 JSON 文件。核心结构如下:
`.env` 只是兜底。配置页保存或测试连接成功后PostgreSQL 中的配置会成为全局默认。
Admin Next 的密钥状态必须按 provider / tool 精确判断:
- 数据库中当前 provider/tool 有密钥时,显示为“已配置”。
- 数据库没有密钥,但 fallback provider、model 或 tool 与当前项匹配时,可以显示 fallback 的脱敏预览。
- 数据库没有密钥且 fallback 不匹配当前项时,显示为“未配置”,不能把其它 provider 的 `.env` 通用 key 当成当前项已配置。
- 脱敏规则保留 key 第一个 `-` 之前的前缀,例如 `sk-********`;明文显示只在有权限的配置页面内按需触发。
工具 key 使用同样规则。WebSearch、OCR 这类工具必须先匹配当前工具和 provider再决定能否使用 fallback。
### 轻量连通性测试
Admin Next 的插头按钮走轻量连通性测试,不承担保存职责。业界常见做法是分两层:
- 快速检查 provider 目录或低成本 endpoint确认 base URL、鉴权和当前模型是否可达。
- 只有在用户明确运行 Playground 或业务任务时才发完整模型请求。
因此,连通性测试应尽量使用低成本请求,并返回明确状态:
- `ok`: 鉴权、路由和模型目录可用。
- `warning`: 服务可达,但当前模型不在目录或能力声明不完整。
- `error`: 鉴权失败、网络失败、协议错误或模型不可用。
错误 toast 标题必须和结果一致,不能在失败时显示“连通性正常”。
### OpenCode Go 路由模型
OpenCode Go 这类订阅通道不要靠前端硬编码模型集合判断协议。推荐在 provider catalog 或后端能力发现中记录每个模型的协议能力,例如 `chat_completions``anthropic_messages``models_endpoint` 和是否需要订阅 key。前端只展示能力结果后端负责把 provider、base URL、model 和协议适配映射为实际请求。
#### 配置页行为
- Provider 下拉框决定当前默认 provider。

View File

@@ -228,9 +228,22 @@ if datasource.last_status == "success":
这个记录用于控制台“采集管理 -> 采集器”中的连接状态判断:如果当前配置和成功采集时的 checksum 一致,就视为已连接,不要求用户再手动点击连接按钮。只有 endpoint、请求头、基础配置或凭证指纹变化时才需要重新验证。
### 采集管理与快照
Admin Next 的采集管理入口按业务层级组织:
- `采集器`:配置 endpoint、认证方式、请求头、基础参数、启用状态和凭证教程。
- `采集调度`:查看和调整调度状态,触发、停止或刷新采集任务。
- `采集历史 / 快照`:按采集器聚合展示历史,详情中用快照选择器查看同一采集器的不同版本。
快照列表不应把同一个采集器的每次快照都拍平成独立主列表项。主列表负责选择采集器,详情区负责时间版本切换。
凭证教程由 `backend/app/services/credential_guides.py` 维护,控制台通过 read / generate / reset 三个动作读取或生成 Markdown 教程。教程面向运维配置人员,前端应渲染 Markdown 正文,而不是展示生成 prompt 或原始元数据。
相关实现见:
- [datasource_connectivity.py](/home/ray/dev/linkong/planet/backend/app/services/datasource_connectivity.py)
- [credential_guides.py](/home/ray/dev/linkong/planet/backend/app/services/credential_guides.py)
- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)
## 八、相关代码文件

View File

@@ -32,8 +32,8 @@
文件:
- [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx)
- [index.css](/home/ray/dev/linkong/planet/frontend/src/index.css)
- [PlainResourcePages.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/pages/PlainResourcePages.tsx)
- [AdminNextRoutes.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/AdminNextRoutes.tsx)
当前行为:
@@ -53,30 +53,35 @@
`data-source-bulk-toolbar__running-pill` 是“采集中”标签的样式入口。它和其他状态标签同排,但通过 hover、箭头和蓝色描边表达可交互性。
### 采集
### 采集管理
文件:
- [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx)
- [PlainResourcePages.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/pages/PlainResourcePages.tsx)
当前行为:
- `collector_credentials` tab 在 `/collection-management` 下展示为“采集器”
- 下拉框列出内置采集器,并支持维护合并到内置数据的自定义补充源
- 下拉框右侧只有一个插头图标按钮,用于健康检查
- 下拉框下方用状态标签展示:
- `需要凭证` / `无需凭证`
- 模块
- `启用` / `禁用`
- `未检查` / `可用` / `不可用`
- 是否覆盖 endpoint
- 需要凭证的采集器把凭证卡片放在基础配置上方。
- 不需要凭证的采集器只显示基础配置。
- `/collection-management` 按旧版层级收敛为 `采集器``采集调度``采集历史 / 快照`
- `采集器` 是配置页,左侧列表展示采集器配置;右侧表单编辑 endpoint、认证、请求头、采集参数和启用状态
- 新增采集器和目标 Schema 使用草稿详情页,不再用透明 JSON 弹窗;保存后才固化到列表,取消会销毁草稿
- 连接按钮只做连通性测试,不保存配置;保存按钮只持久化表单。
- 凭证教程以可拖拽 Markdown 弹窗展示。没有教程时仍打开弹窗并提供生成入口;生成时显示中心等待状态;重置确认层级必须高于教程弹窗。
- `采集历史 / 快照` 按采集器聚合。列表不再重复展示同一数据源的所有快照;详情页内部用 Time Capsule / Time Machine 式选择器切换快照版本。
- AISStream 采集器使用 WebSocket 语义,状态会显示为连接中、实时接收、重连或停止,不使用固定百分比表达完成度。
- 自定义源入口放在采集器设置内,不在数据源目录里重复提供编辑入口;数据源目录只保留总览、运行和只读抽屉。
连接按钮使用内联 Tabler 风格插头图标,来源语义对应 `plug-connected`,避免继续使用刷新图标表达连接动作。
### 映射模板和目标 Schema
映射能力仍属于采集管理,但不是采集器主 tab
- `映射模板` 承担 sample payload、AI propose、preview、create/update、activate。
- `目标 Schema` 维护可写入目标结构。
- `run-mapped``stop-mapped``stream-status` 负责运行映射后的自定义采集器。
这些入口在新版中需要表单化,只有高级字段才折叠为 JSON。不要把模板、Schema、运行状态和采集器配置拍平成同一张表。
## 后端接口
### 数据源配置列表
@@ -140,6 +145,26 @@ POST /api/v1/datasources/configs/builtin/connect
- `credential_provider`
- `credential_source`
### 凭证教程
```http
GET /api/v1/datasources/credential-guides/{provider}
POST /api/v1/datasources/credential-guides/{provider}/generate
POST /api/v1/datasources/credential-guides/{provider}/reset
```
用途:
- 读取当前 provider 的 Markdown 凭证教程。
- 没有教程时,由 AI 根据采集器元数据生成教程。
- 重置时恢复后端默认教程。
前端展示规则:
- 展示渲染后的 Markdown不展示后端 prompt 或元数据。
- 生成/重置是教程弹窗内部动作,不放在采集器配置主工具栏。
- 没有教程也要打开弹窗,让用户能从弹窗里生成教程。
### BarentsWatch AIS 连接验证
```http

View File

@@ -66,10 +66,10 @@ DocsMetadata(
)
```
新增公开文档时,需要同步:
新增可在 Docs 页面展示的技术文档时,需要同步:
- 新增中英文 Markdown 文件。
- 在服务端 `DOCS_METADATA` 添加 filename、slug、access、group、order、标题。
- 在服务端 `DOCS_METADATA` 添加 filename、slug、access、group、order、标题。后端目录接口以这里为准,只改前端 metadata 不会让文档出现在 `/docs` 导航中。
- 在前端 [docs-content.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-content.ts) 添加同名 metadata保持导航标题和排序一致。
- 如果需要从 README 发现,更新 `docs/technical/zh/README.md``docs/technical/en/README.md`

View File

@@ -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)
@@ -156,6 +160,22 @@ Earth 地表是多层近似同心球,不是单一 mesh。`earth.js` 的基座
`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 前端只消费结果。
`about.js` 管理 Earth 设置里的“关于”卡片。默认内容仍保留在前端作为兜底,运行时优先读取 `/api/v1/earth/about`。接口失败或字段缺失时必须回退默认值避免设置页出现空白。Admin Next 的 Earth 内容页提供“关于”tab保存走 `PUT /api/v1/earth/about`,恢复默认走 `DELETE /api/v1/earth/about`
`oobe.js` 管理 Earth 首次初始化引导。是否显示 OOBE 必须由 `/api/v1/earth/oobe-status``ready` 字段决定,不能依赖 `localStorage` 判断系统是否初始化。`localStorage` 只允许记录“本浏览器暂时跳过”的短时状态;如果后端已经认为 `ready: true`,退出登录、清空本地缓存或换浏览器都不应再次弹出 OOBE。桌面端使用深色星空遮罩和毛玻璃启动面板移动端改为底部 sheet并尊重 `prefers-reduced-motion`
Admin Next 的 Earth 内容页必须按运行时语义组织这些配置:
- `品牌标识`:品牌预览应使用与 Earth HUD 左上角一致的深色星空背景、尺寸、间距、logo/title 渲染和文本 fallback而不是普通表单预览。
- `关于`:配置 Earth 设置里的 About 卡片,包括 logo、眉标、标题、版本、描述和元信息条目Earth 运行时从 `/earth/about` 读取,失败时回退默认内容。
- `国界精度`:构建边界、刷新状态和恢复默认属于这个分区内部动作,不应放在页面全局工具栏。
- `电视直播`:列表同时区分内置源、采集源和自定义源;卡片状态表达启用、停用、新建或错误。内置源不能删除,采集源和自定义源可以删除。新增直播源在点击加号后才进入草稿状态,保存后固化到列表,取消则销毁草稿。
- `底图资源``图层资源``3D 模型``新闻锚点策略`:如果后端能力未接入,控制台应明确显示待接入空态,不应混入 TV 或品牌配置。
TV 预览需要尽量复用 Earth 运行时的直播卡片结构和状态标签,这样控制台看到的内置、直播加载中、播放源、地区、语言等信息与 Earth 中实际显示保持一致。
其中 Earth 启动加载链现在也拆成了两层:
- `controls.js`

View File

@@ -342,6 +342,12 @@ Docs 按 Gatekeeper 权限组控制可见性:
算力中心候选保存后会写入 `compute_center_locations`。没有可用候选的记录会保留在待定位列表中,系统不会用国家中心点或硬编码 hint 伪造位置。
### Earth 品牌 logo 或标题改完后为什么没恢复默认?
Earth 品牌资源在控制台 `运维与配置 -> Earth 内容 -> 品牌资源` 中维护。上传图片后页面会使用返回的 Earth 品牌资产地址如果只是清空标题、ARIA 文案等文本字段,系统会回退到默认标题,避免出现空白品牌。
要恢复发布包自带的默认 logo、标题图和文案使用“重置品牌资源”。只刷新 Earth 页面不会删除已经保存的运行时品牌配置。
### 为什么卫星看起来不在同一个球面上?
Earth 默认开启“真实卫星高度”。卫星位置仍来自 TLE/SGP4但高度会经过压缩映射低轨卫星靠近地球高轨卫星更远同时保持在当前视图可读范围内。最高显示偏移使用 `25`,约等于当前地球显示半径的四分之一;这是视觉上区分 GEO / MEO / LEO 和保持镜头可读性的折中,不是把真实公里数按比例直接画出来。这个设置也会影响卫星轨迹和锁定后的预测轨道。

View File

@@ -22,7 +22,7 @@
- [App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx)
当前后台相关路由包括
当前正式后台路由已经由 Admin Next 接管
- `/admin`
- `/users`
@@ -37,15 +37,45 @@
- `/collection-management`
- `/settings`
这些路径渲染 [AdminNextRoutes.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/AdminNextRoutes.tsx),页面清单和菜单元信息来自 [manifest.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/routes/manifest.tsx)。`/admin-next/*` 仅作为兼容入口存在,会重定向到上面的正式路径,不再作为并行主入口。
旧 AntD 控制台保留在 `/legacy/admin/*`,用于对照和回退:
- `/legacy/admin`
- `/legacy/admin/datasources`
- `/legacy/admin/data`
- `/legacy/admin/collection-management`
- `/legacy/admin/earth-content`
- `/legacy/admin/ai`
- `/legacy/admin/logs`
- `/legacy/admin/settings`
- `/legacy/admin/users`
- `/legacy/admin/bgp`
- `/legacy/admin/alerts/*`
旧页面、`AppLayout``antd``@ant-design/icons` 在 legacy 验收期继续保留。不要在新版 parity 验收前删除这些文件或依赖。
`/earth` 是独立展示页,不属于控制台骨架。
## 当前页面骨架
控制台公共壳层在:
正式后台公共壳层在:
- [AdminNextLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/components/layout/AdminNextLayout.tsx)
职责:
- 左侧导航、分组折叠和移动端抽屉
- 当前账号、版本、退出登录和主题切换
- 顶部搜索、面包屑和页面快捷入口
- 内容区单屏高度闭合
- Admin Next 内部滚动、表格、详情面板和移动端详情视图协调
旧 AntD legacy 壳层仍在:
- [AppLayout.tsx](/home/ray/dev/linkong/planet/frontend/src/components/AppLayout/AppLayout.tsx)
职责:
legacy 职责:
- 左侧导航
- 折叠与展开
@@ -66,7 +96,40 @@
</Layout>
```
后续控制台页面应优先适配这套壳层,而不是重新定义全页高度语义
后续正式控制台页面应优先适配 `AdminNextLayout` 和 Admin Next 页面模式,而不是继续往旧 `AppLayout` 增加新能力。只有维护 `/legacy/admin/*` 时才应修改旧壳层
## Admin Next 分区加载策略
多 tab 页面由 [PlainResourcePages.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/pages/PlainResourcePages.tsx) 统一承载当前的管理型和信息型工作台。分区加载规则是:
- 初次进入页面只请求当前 active tab不预先拉取所有 tab 的接口。
- 用户切换 tab 时懒加载该 tab已经加载过的 tab 保留在本地 `states` 缓存中,切回时直接复用。
- 手动刷新、保存、测试、上传、生成教程等明确动作只刷新当前分区,避免把无关分区的接口一起打出去。
- 数据源目录的筛选条件变化会清掉内置源分区缓存,并按新筛选重新请求当前分区。
- 顶部 summary 只统计“已加载分区”,不把未访问 tab 误报成异常接口。
这样做是为了降低 Earth、AI、采集管理等多分区页面的冷启动压力同时保留 tab 数量、状态和用户切换后的缓存体验。需要全量健康巡检时应走后端健康接口或显式刷新流程,不要依赖页面初始化时顺手拉所有业务接口。
## 数据源采集队列
Admin Next 的数据源页把单源触发、表格勾选触发和触发全部统一接入浏览器下载列表式采集队列:
- 队列状态由 [PlainResourcePages.tsx](/home/ray/dev/linkong/planet/frontend/src/admin-next/pages/PlainResourcePages.tsx) 管理,只保存当前会话中的可见任务,不用 `localStorage` 伪造历史。
- 任务进度优先消费 `/ws``datasource_tasks` channel如果 WebSocket 未连接或没有及时返回,则轮询 `/api/v1/datasources/{id}/task-status`
- 触发接口返回的 `triggered``skipped``failed` 会立即进入队列;刷新页面后只根据后端当前仍在 `running/pending/queued` 的数据源恢复队列。
- `内置源` 分区通过表格选择列收敛批量触发。没有勾选时主按钮是“触发全部”;勾选后同一个主按钮变成“触发已选 N”不再提供手填 ID 的独立批量按钮。
- 页面内容流不再承载展开队列,避免全量触发后挤压列表和详情面板。右上角 actions 区的队列按钮沿用现有 `Button` 样式;空态使用 `ListChecks` 图标,有任务时只显示纯圆环总进度。点击后打开浮层,按运行中、失败、完成、跳过分组。
- 队列项可以跳转到对应数据源详情,失败项可以重试。详情页内的“采集任务”摘要只展示当前数据源最近任务,不承担保存配置职责。
这个队列是用户感知层,不替代后端调度状态。后端仍然是任务是否运行、完成、失败或跳过的唯一事实来源。
## Admin Next 主题滑块
Admin Next 侧栏底部主题切换继续复用共享 [SegmentedControl.tsx](/home/ray/dev/linkong/planet/frontend/src/components/SegmentedControl/SegmentedControl.tsx),但主题变量在 [styles.css](/home/ray/dev/linkong/planet/frontend/src/admin-next/styles.css) 内跟随 `data-theme` 覆盖:
- light 下使用 `--d-segment-bg: #eef3f9`、白色 slider 和轻投影。
- dark 下使用与 Docs 一致的深色底座、`#202938` slider 和深色外投影。
- 业务侧只隐藏文字 label 并保留 icon + tooltip不重写 slider DOM。
## 当前共享组件
@@ -74,7 +137,8 @@
文件:
- [Scrollbar.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/Scrollbar.tsx)
- [Scrollbar.tsx](/home/ray/dev/linkong/planet/frontend/src/components/tactile-ui/Scrollbar.tsx)
- [Tactile UI 组件库](/home/ray/dev/linkong/planet/docs/technical/zh/tactile-ui-components.md)
用途:
@@ -91,7 +155,7 @@
文件:
- [ScrollbarOverlay.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/ScrollbarOverlay.tsx)
- [ScrollbarOverlay.tsx](/home/ray/dev/linkong/planet/frontend/src/components/tactile-ui/ScrollbarOverlay.tsx)
用途:
@@ -100,25 +164,42 @@
当前使用场景:
- 数据源
- 采集数据
- 用户管理
- 设置页
- 告警页
- BGP 页面
- Admin Next 数据源、采集数据、采集管理、日志、告警和 BGP 页面
- 旧 AntD legacy 页面通过兼容封装继续使用共享滚动能力
### 3. `TableScrollRegion`
文件:
- [TableScrollRegion.tsx](/home/ray/dev/linkong/planet/frontend/src/components/Scrollbar/TableScrollRegion.tsx)
- [TableScrollRegion.tsx](/home/ray/dev/linkong/planet/frontend/src/components/tactile-ui/TableScrollRegion.tsx)
用途:
- 为表格滚动区提供统一包裹层
- 后续新表格页优先复用,不要重复写“表格区域 + overlay scrollbar”样板
### 4. `SegmentedControl`
### 4. `TactileButton` / `TactileSwitch` / `ControlGroup`
文件:
- [Button.tsx](/home/ray/dev/linkong/planet/frontend/src/components/tactile-ui/Button.tsx)
- [Switch.tsx](/home/ray/dev/linkong/planet/frontend/src/components/tactile-ui/Switch.tsx)
- [ControlGroup.tsx](/home/ray/dev/linkong/planet/frontend/src/components/tactile-ui/ControlGroup.tsx)
用途:
- Admin Next 全局工具按钮和详情页工具按钮
- icon-only + tooltip 的普通操作
- 保存、创建、确认、删除、停止等强意图操作
- 与 Docs 主题滑块一致的紧凑开关
当前约束:
- 默认按钮是白色触感按钮,通过外部投影表达轻微立体感
- 无歧义动作优先图标 + tooltip保存这类强意图动作可保留文字
- 业务页面通过 props 和 CSS variables 调整,不要直接手写按钮核心样式
### 5. `SegmentedControl`
文件:
@@ -146,7 +227,7 @@
- 颜色优先通过 CSS 变量覆盖,避免在业务组件里硬编码主题色
- 适合少量互斥选项,不适合用作长列表、导航菜单或表单下拉
### 5. `MarkdownRenderer`
### 6. `MarkdownRenderer`
文件:
@@ -165,7 +246,7 @@
- 文档内部链接应通过 `transformLink` 转成 `/docs/:slug`
- 标题锚点由 `getHeadingId` 注入,避免渲染器自己理解路由状态
### 6. `ConnectionTestInput`
### 7. `ConnectionTestInput`
文件:
@@ -183,7 +264,7 @@
- 禁用的集成能力必须同时置灰输入框和连接测试按钮
- 组件只负责输入框与测试入口组合不保存业务状态调用方仍负责表单值、loading、disabled 和连接请求
### 7. `TableActions`
### 8. `TableActions`
文件:

View File

@@ -190,19 +190,23 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
`/earth-content` 位于控制台“运维与配置”下,面向 Earth 前端体验资源:
- **品牌资源**:维护 Earth HUD 使用的 logo、标题图、标题文本、副标题和描述上传的图片会保存为 Earth 品牌资产并立即供 Earth 页面读取。
- **关于**:维护 Earth 设置面板里的关于卡片,包括 logo、眉标、标题、版本、描述和元信息。
- **电视直播**:维护 Earth 媒体面板里的直播源。
- **国界精度**:查看当前国界 provider、低精 fallback、高精 PMTiles/manifest 状态,编辑本机源配置并手动构建。
- **地球底图**、**图层资源**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。
Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时,如果本机尚未构建高精资产,会像游戏更新包一样启动后台下载/构建并显示百分比;构建成功后自动应用,无需刷新。切回“低精”只切换本机显示偏好,不重新下载。
如果后端判断 Earth 尚未初始化,首次进入 `/earth` 会出现毛玻璃引导,提示登录控制台并采集数据。这个判断来自后端真实数据状态;如果系统已经有已采集数据,清空浏览器缓存也不会重新弹出。
### 采集管理
`/collection-management` 位于控制台“运维与配置”下,面向采集生命周期:
- **采集器**:维护 endpoint、请求头、凭证、timeout、retry并运行连接检查。
- **采集调度**:维护原有调度相关设置。
- **采集历史 / 快照**当前是待接入占位页,后续承载 collection task、snapshot、collected data 浏览能力
- **采集历史 / 快照**按数据源聚合历史快照,详情页可用 Time Capsule 下拉切换不同版本
### SMTP 邮件设置
@@ -232,7 +236,7 @@ Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时
## 数据探索
- `/datasources`:数据源目录。`采集任务` tab 面向一次性/定时采集器,可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“一键采集”触发当前筛选范围`实时` tab 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表
- `/datasources`:数据源目录。`内置源` 支持按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选时主按钮显示“触发全部”,勾选多行后会变成“触发已选 N”并只提交所选数据源。右上角队列按钮空态显示队列图标有任务时显示纯圆环总进度点击后打开队列浮层按运行中、完成、失败和跳过分组失败项可重试完成项可跳到详情`实时` 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。
- `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录"
- `/bgp`BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补
- `/alerts/system``/alerts/bgp``/alerts/situational`系统、BGP、态势告警
@@ -283,7 +287,7 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
### 设置
设置面板包含旋转模式 / 巡航模式 / 动捕模式、巡航模块BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、地球默认大小、地形透明度、重置设置。
设置面板按分类组织:运行、显示、面板、动捕、快捷键、系统。里面包含旋转模式 / 巡航模式 / 动捕模式、巡航模块BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、悬停提示、卫星呼吸闪烁、真实卫星高度、轨迹显示、低缩放圆点、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、快捷键启用与改键、地球默认大小、地形透明度、重置设置。
“真实卫星高度”默认开启:卫星会按 TLE/SGP4 算出的真实轨道高度做压缩分层显示,低轨仍靠近地球,高轨会更远但不会脱离当前视图。高轨显示高度会被压到地球半径外约四分之一以内,这样 GEO / MEO 仍能和 LEO 分层,但不会把视线、轨迹和选择操作拉得过散;关闭后恢复旧版所有卫星位于同一显示球面的效果。“轨迹显示”控制卫星轨迹线显隐,卫星图层关闭时轨迹也不可见。
@@ -291,6 +295,8 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
这些设置保存在浏览器本地存储,换浏览器或清理站点数据后会恢复默认值。
快捷键也保存在浏览器本地。可以在设置里的“快捷键”分类中单独禁用、重设某个快捷键,或恢复默认键位;这只影响当前浏览器。
### 视角控制
| 操作 | 作用 |

View File

@@ -0,0 +1,99 @@
# 命名与术语对照
本文约定 Planet 控制台、Earth、后端 API 和文档中的常见名词。新增 UI 文案、接口字段展示、图表标签和文档说明时,优先使用这里的中文名,避免同一页面中出现不必要的中英混合。
## 使用规则
- 面向中文界面的可见标签使用“中文显示名”。
- API 字段、数据库字段、代码类型和外部品牌名保留英文或 snake_case。
- 专有名词第一次出现时可以写成“中文显示名English Term之后使用中文显示名。
- BGP、AI、API、URL、ID、TLE、ASN、GPU、AIS 等行业缩写可保留英文。
- 外部服务品牌保留官方英文名,例如 PeeringDB、Space-Track、BarentsWatch、AISStream、GitHub。
## 核心产品名词
| English / Key | 中文显示名 | 使用场景 |
| --- | --- | --- |
| Planet | Planet | 产品名,保留英文 |
| Admin Next | 新控制台 | 新版管理端上下文 |
| Earth | Earth | 地球可视化产品名,保留英文 |
| datasource | 数据源 | API、列表、筛选 |
| collector | 采集器 | 采集任务、凭证配置 |
| collected data | 采集数据 | 数据列表、统计 |
| snapshot | 快照 | 采集历史 |
| mapping | 映射 | 采集字段映射 |
| target schema | 目标 Schema | 映射目标结构 |
| credential | 凭证 | 密钥、账号密码、OAuth 信息 |
| secret reveal | 密钥 reveal | 管理员显式查看密钥动作,保留 reveal 表示审计语义 |
| AI Provider | AI Provider | 服务名,保留英文 |
| tool | 工具 | Web Search、OCR 等工具配置 |
| Playground | Playground | 交互调试入口,保留英文 |
## 数据类型
| data_type | 中文显示名 | 说明 |
| --- | --- | --- |
| `supercomputer` | 超算 | TOP500 超算记录 |
| `gpu_cluster` | GPU 集群 | Epoch AI GPU 集群 |
| `model` | 模型 | Hugging Face 模型 |
| `dataset` | 数据集 | Hugging Face 数据集 |
| `space` | 空间 | Hugging Face Spaces |
| `ixp` | 互联网交换点 | PeeringDB IXP |
| `network` | 网络 | PeeringDB 网络 |
| `facility` | 设施 | PeeringDB 设施 |
| `submarine_cable` | 海底光缆 | 海缆线路 |
| `landing_point` | 登陆点 | 海缆登陆点 |
| `cable_system` | 海缆系统 | TeleGeography cable system |
| `cable_landing_relation` | 海缆登陆关系 | 海缆与登陆点关系 |
| `satellite_tle` | 卫星轨道根数 | CelesTrak / Space-Track TLE |
| `bgp_rib` | BGP 路由表 | BGP RIB 快照 |
| `bgp_update` | BGP 更新 | BGP update 事件 |
| `prefix_geography` | 前缀地理位置 | IP 前缀地理位置 |
| `vessel_ais` | AIS 船舶 | AIS 船舶观测 |
| `vessel` | 船舶 | 聚合后的船舶对象 |
| `news_live_stream` | 新闻直播源 | 新闻直播源配置 |
| `news_item` | 新闻条目 | 新闻归档条目 |
| `device_stats` | 设备统计 | Cloudflare Radar 设备统计 |
| `traffic_stats` | 流量统计 | Cloudflare Radar 流量统计 |
| `as_stats` | 自治系统统计 | Cloudflare Radar AS 统计 |
| `compute_center` | 算力中心 | Earth 聚合展示对象 |
| `generic` | 通用数据 | 通用采集输出 |
| `generic_records` | 通用记录 | 通用映射输出 |
## 数据源与外部服务
| English / Key | 中文显示名 | 说明 |
| --- | --- | --- |
| TOP500 | TOP500 超算榜单 | 外部榜单名保留 TOP500 |
| Epoch AI | Epoch AI GPU 集群 | 外部服务名保留 Epoch AI |
| Hugging Face | Hugging Face | 官方品牌名 |
| PeeringDB IXP | PeeringDB 交换中心 | IXP 可补充写作“互联网交换点” |
| PeeringDB Networks | PeeringDB 网络 | 网络实体 |
| PeeringDB Facilities | PeeringDB 设施 | 机房 / 设施 |
| Submarine Cables | 海底光缆 | 图表和列表优先用中文 |
| Cable Landing Points | 海缆登陆点 | 不是“着陆点” |
| CelesTrak TLE | CelesTrak 轨道根数 | TLE 可保留缩写 |
| Space-Track TLE | Space-Track 轨道根数 | Space-Track 保留品牌名 |
| RIPE RIS Live BGP | RIPE RIS Live BGP | 外部服务名和 BGP 缩写保留英文 |
| IPtoASN Prefix Geography | IPtoASN 前缀地理位置 | IPtoASN 保留品牌 / 数据集名 |
| OpenGeoFeed Prefix Geography | OpenGeoFeed 前缀地理位置 | OpenGeoFeed 保留品牌名 |
| NRO Delegated Prefix Geography | NRO Delegated 前缀地理位置 | NRO Delegated 保留数据集名 |
| BarentsWatch AIS Vessels | BarentsWatch AIS 船舶 | BarentsWatch / AIS 保留 |
| AISStream Vessels | AISStream 船舶 | AISStream 保留品牌名 |
## 界面动作与状态
| English / Key | 中文显示名 | 使用场景 |
| --- | --- | --- |
| success | 成功 | 状态徽标 |
| failed | 失败 | 状态徽标 |
| running | 运行中 | 任务状态 |
| completed | 已完成 | 任务状态 |
| cancelled | 已取消 | 任务状态 |
| enabled | 已启用 | 开关状态 |
| disabled | 已停用 | 开关状态 |
| configured | 已配置 | 凭证 / 集成状态 |
| unconfigured | 未配置 | 凭证 / 集成状态 |
| refresh | 刷新 | 按钮 |
| copy | 复制 | 按钮 |
| reveal | reveal | 密钥显示动作,因审计语义保留英文 |

View File

@@ -21,7 +21,7 @@
| 用户名 | 密码 | 角色 |
| --- | --- | --- |
| `admin` | `admin123` | `super_admin` |
| `linkong` | `12345678` | `super_admin` |
| `linkong` | `LK12345678` | `super_admin` |
两个默认账号 `email_verified``true`,可直接登录控制台。任何在该列表之外的账号都必须走公开注册 + 邮箱验证流程(见使用手册),或用 `./planet.sh createuser` 命令式创建。
@@ -61,6 +61,22 @@
按模块重启适合日常开发,能避免无关服务被打断。
## 破坏性重置
```bash
./planet.sh destroy
```
`destroy` 用于把本地开发环境退回到接近空项目的状态。执行前需要输入 `Y` 确认;源码和现有 `.env` 配置文件会保留。
清理顺序和边界:
- 如果 `planet_postgres` 正在运行,脚本会先清空 `planet_db``public` schema。这样即使后续 Docker volume 删除失败,旧的 `collected_data.is_current = true` 也不会让 Earth OOBE 继续显示 `ready=true`
- Docker 清理只针对 Compose project 为 `planet` 的资源,以及显式列出的 `planet_postgres_data``planet_redis_data``postgres_data``redis_data`;不要按 `planet_*` 模式删除没有 label 的 volume避免误删同机其他项目。
- 本地编译状态会删除 `.venv`、前端 `node_modules` / `dist`、Planet state/cache以及散落的 Python / Vite 缓存目录。
重置后重新执行 `./planet.sh init` 会重建表和默认数据,但不会恢复旧采集结果;首次进入 Earth 时 OOBE 会重新按后端真实采集状态判断。
## 健康检查
```bash
@@ -87,8 +103,8 @@
持续跟随:
```bash
./planet.sh log -f # 前端: /tmp/planet_frontend.log
./planet.sh log -b # 后端: /tmp/planet_backend.log
./planet.sh log -f # 前端: ~/.local/state/planet/frontend.log
./planet.sh log -b # 后端: ~/.local/state/planet/backend.log
./planet.sh log -a # AI Provider: planet_aiprovider 容器日志
```
@@ -234,6 +250,20 @@ bun run build
source ~/.zshrc && bun run build
```
开发时继续使用 `bun run dev`Vite HMR 会在保存源码后刷新浏览器。`bun run build` 只生成 `dist` 产物,不会刷新已经打开的 dev 页面。
需要看生产包并在构建成功后自动刷新时使用:
```bash
bun run preview:auto
```
只想监听源码并持续构建,不启动预览服务时使用:
```bash
bun run build:watch
```
后端依赖通过 uv 管理:
```bash

View File

@@ -34,7 +34,7 @@
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 -> 实时` 的健康状态和计数
3. `/datasources``/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 内置源`,不勾选时点“触发全部”,勾选后主按钮会变成“触发已选 N”右上角队列按钮可查看进度。AISStream / WebSocket 长连接看 `/datasources -> 实时` 的健康状态和计数
4. `/alerts/system`:看系统告警是否正常
5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组
@@ -42,6 +42,8 @@
访问 `/earth`,公开页面,不需要登录。
如果系统还没有已采集数据Earth 会显示初始化引导,提示登录控制台并触发采集。这个引导由后端状态决定,不会因为清空浏览器缓存而误判。
进入后建议确认:
- 地球正常显示,右侧图层面板可以打开/关闭

View File

@@ -0,0 +1,223 @@
# Tactile UI 组件库
Tactile UI 是 Planet 内部抽出的可移植 React 控件层。它来自 Admin Next 的按钮、开关、滚动条和 tooltip 收口,但组件本身不依赖 Admin Next、AntD、Radix、Tailwind 或 `an-*` class。目标是先在仓库内稳定使用后续可以作为独立 npm 包发布。
## 设计目标
- **轻触感**:默认控件使用白色或主题表面、细边框和外部投影,接近 Docs 主题滑块的轻微立体感,不使用大色块或发光效果。
- **可移植**:组件 class 使用 `tui-*` 前缀,样式集中在 `frontend/src/components/tactile-ui/styles.css`
- **低依赖**:组件只假设 React/React DOM图标预设当前使用 `lucide-react`,调用方也可以传自定义 React 节点。
- **主题友好**:默认样式通过 CSS variables 暴露Planet 可以在 Admin Next 或其它页面按主题覆盖 token。
- **语义清楚**:无歧义操作优先 icon-only + tooltip保存、确认、创建、执行这类强意图操作可以保留文字。
## 导入
仓库内使用:
```tsx
import { TactileButton, TactileSwitch, ControlGroup } from '@/components/tactile-ui'
import '@/components/tactile-ui/styles.css'
```
未来拆包后建议导入形式:
```tsx
import { TactileButton, TactileSwitch } from '@planet/tactile-ui'
import '@planet/tactile-ui/styles.css'
```
## 主题 Token
核心 token 在 CSS 中以 `--tui-*` 暴露。业务主题只需要覆盖变量,不应直接重写组件内部 class。
```css
:root {
--tui-surface: #ffffff;
--tui-surface-raised-hover: #f8fbff;
--tui-border-soft: #d6dfeb;
--tui-border-hover: #b8c6d9;
--tui-text: #0f172a;
--tui-primary: #2563eb;
--tui-danger: #dc2626;
}
[data-theme='dark'] {
--tui-surface: #111827;
--tui-text: #e5edf8;
}
```
组件还支持通过 `tactile` prop 传入局部尺寸、圆角、背景、边框和阴影参数。局部参数适合少量特殊按钮;全局视觉应优先走 CSS variables。
## Portal 与深色主题
`TactileTooltip` 和使用 Tactile UI 的 Admin Next `Dialog``Select`、Toast 都可能通过 portal 挂到 `document.body`。这类节点不在 `.admin-next-theme-root[data-theme='dark']` 下面,不能只依赖局部祖先选择器读取深色 token。
Admin Next 的主题 provider 会把当前主题同步到 `body[data-admin-next-theme]`。共享样式必须同时支持两类选择器:
```css
[data-theme='dark'] .tui-button,
body[data-admin-next-theme='dark'] .tui-button {
--tui-surface: #172033;
--tui-text: #e5edf8;
}
```
新增 portal 控件时,先确认它是否渲染到 body。如果是就要在组件自己的样式入口补 `body[data-admin-next-theme='dark']` 分支,或复用已经覆盖过的 `--tui-*` / `--an-*` token。不要在单个弹窗里手写固定深色因为同一问题会在下拉菜单、tooltip、toast 和确认弹窗里重复出现。
## `TactileButton`
按钮组件覆盖普通按钮、图标按钮、强意图按钮和链接式按钮。
常用 props
| Prop | 说明 |
| --- | --- |
| `variant` | `neutral``primary``danger``subtle``ghost` |
| `size` | `sm``md``lg``icon` |
| `shape` | `square``pill` |
| `icon` | 预设图标名称或自定义 React 节点 |
| `iconOnly` | 图标按钮固定尺寸,并要求提供 `tooltip``aria-label` |
| `tooltip` | 通过 portal 渲染,默认避开光标位置 |
| `loading` | 禁用按钮并暴露 `aria-disabled` |
| `tactile` | 覆盖宽高、圆角、阴影、背景和深色模式变量 |
```tsx
<TactileButton icon="refresh" iconOnly tooltip="刷新" />
<TactileButton variant="primary" icon="save"></TactileButton>
<TactileButton variant="danger" icon="delete" iconOnly tooltip="删除" />
```
`variant="neutral"` 的默认按钮是白色触感按钮。彩色按钮仍应保留外部投影和统一高度,不应在业务 CSS 中手写新的阴影体系。
有色按钮的边框不能直接使用填充色本身。`primary``danger` 和后续新增的有色 variant 应使用同色系减淡边框,例如 `color-mix(in srgb, var(--tui-danger) 64%, white)`。这样边框仍然属于按钮色相,但视觉重量弱于填充面,避免红色/蓝色按钮看起来比默认按钮额外大一圈。hover 态应提亮而不是压暗,背景用当前色混入少量 white边框继续比背景更轻。
## 图标预设
预设图标由 `tactileIconPresets` 统一维护,业务页面通过语义名称调用,避免每个页面随意选择图标。
常用语义:
| 名称 | 用途 |
| --- | --- |
| `refresh` | 刷新数据 |
| `save` | 保存 |
| `delete` | 删除 |
| `trigger` / `collect` | 触发采集 |
| `start` / `play` | 启动 |
| `stop` | 停止 |
| `connect` / `test` | 连接或连通性测试 |
| `guide` | 凭证教程 |
| `generate` | AI 生成 |
| `reset` | 恢复默认 |
| `detail` | 查看详情 |
| `copy` | 复制 |
| `upload` | 上传 |
如果预设不足,可以直接传 React 节点:
```tsx
<TactileButton icon={<MyIcon aria-hidden="true" />} iconOnly tooltip="自定义动作" />
```
## `TactileSwitch`
开关组件用于二元设置。它不是 iOS 风格大开关,而是与 Docs 主题滑块一致的小型轻触感控件。
```tsx
<TactileSwitch
checked={enabled}
onCheckedChange={setEnabled}
label="启用 WebSearch"
tooltip="启用 WebSearch"
/>
```
文案默认进入 tooltip。需要显示文字时由调用方在布局中单独放 label不应把长文案塞进 switch 内部。
## `ControlGroup`
`ControlGroup` 用于把一组按钮按统一间距和对齐方式排列。默认没有灰色底座;只有显式传 `withBase``baseTactile` 时才显示底座。
```tsx
<ControlGroup align="end" gap={8}>
<TactileButton icon="refresh" iconOnly tooltip="刷新" />
<TactileButton variant="primary" icon="save"></TactileButton>
</ControlGroup>
```
用于列表底部时,按钮应随着列表滚动出现,而不是固定漂浮在列表中部。用于详情页工具栏时,按钮高度和阴影应由组件默认值统一。
## `TactileTooltip`
Tooltip 使用 `createPortal` 挂到 `document.body`,支持 `top``right``bottom``left` 和 collision 修正。默认位置会相对触发元素向右下偏移,避免被鼠标指针挡住。
```tsx
<TactileTooltip label="刷新" anchor={buttonRef.current} open={open} side="bottom" />
```
应用层通常不需要直接使用它,`TactileButton``TactileSwitch` 已经内置 tooltip。
## `Scrollbar`
`Scrollbar` 包裹普通滚动容器。真实滚动仍由内部 viewport 承担Tactile UI 只提供覆盖式 track/thumb。
```tsx
<Scrollbar axis="both" minThumbSize={28} autoHide>
<LongContent />
</Scrollbar>
```
常用 props
| Prop | 说明 |
| --- | --- |
| `axis` | `x``y``both` |
| `autoHide` | 非 hover/拖拽时自动弱化 |
| `alwaysVisible` | 始终显示滚动条 |
| `trackSize` / `thumbSize` | 控制轨道和 thumb 尺寸 |
| `inset` / `radius` | 控制贴边距离和圆角 |
| `thumbColor` / `trackColor` | 局部颜色覆盖 |
| `viewportRef` | 由外部提供真实滚动节点 |
Textarea 不建议强行套 overlay 滚动条,因为浏览器 resize grip 和文本选择需要原生交互。需要统一外观时,应使用专门的 resizable textarea 样式,让原生 grip 保留可点击区域。
## `ScrollbarOverlay`
`ScrollbarOverlay` 适合已经存在滚动节点的区域,例如第三方表格或自定义 viewport。它不会创建新的滚动容器只会监听目标节点并画覆盖式滚动条。
```tsx
<div ref={hostRef}>
<ThirdPartyTable />
<ScrollbarOverlay containerRef={hostRef} targetSelector=".tui-scroll-target" />
</div>
```
## `TableScrollRegion`
`TableScrollRegion` 是表格滚动区域的便利封装。默认目标选择器是 `.tui-scroll-target`Admin Next 会显式传自己的 table viewport selector避免组件库里出现业务命名。
```tsx
<TableScrollRegion targetSelector=".table-viewport">
<EntityTable />
</TableScrollRegion>
```
## 可访问性
- `iconOnly` 按钮必须提供 `tooltip``aria-label`
- `TactileSwitch` 使用 `role="switch"``aria-checked`
- tooltip 只是说明不承担唯一状态表达状态仍应通过文本、badge 或 `aria-*` 呈现。
- 禁用和 loading 状态会设置 `aria-disabled`button 元素会同步 `disabled`
## Admin Next 迁移约定
Admin Next 中所有全局工具按钮、详情页工具按钮和列表底部动作应使用 Tactile UI
- 无歧义动作:`TactileButton iconOnly tooltip`
- 保存/创建/确认:`TactileButton variant="primary"`,通常保留文字
- 删除/停止:`TactileButton variant="danger"`,根据风险决定是否保留文字
- 开关:`TactileSwitch`
- 长列表、表格、日志、Markdown`Scrollbar` / `ScrollbarOverlay` / `TableScrollRegion`
这样做的边界是组件负责触感、尺寸、阴影、tooltip 和滚动条行为;业务页面负责 API、状态机、权限和文案。

View File

@@ -16,12 +16,21 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.59.0`
- `dev` 当前开发分支历史推导到:`0.65.2`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.65.2` | bugfix | `dev` | `pending` | AI Provider 镜像重建判定改为内容 fingerprint 与镜像 label启动链路改用 frozen uv避免用户级镜像源污染 `uv.lock`,并加入 Windows 一键启动脚本 |
| `0.65.1` | bugfix | `dev` | `pending` | 统一 `planet.sh` 与 Compose 的 AI Provider 镜像名,并让本地和 Docker build 通过用户级 `uv.toml` 共享 uv 源配置,避免镜像源污染 `uv.lock` |
| `0.65.0` | feature | `dev` | `pending` | Admin Next 数据源触发入口收敛为“触发全部/触发已选 N”队列改为右上角浮层按钮并强化 `planet.sh destroy` 的 OOBE 数据清理 |
| `0.64.0` | feature | `dev` | `pending` | Earth 新增后端状态驱动 OOBE 与 About 配置Admin Next 数据源页新增采集队列,补齐深色主题滑块和用户/技术文档 |
| `0.63.1` | bugfix | `dev` | `pending` | 补上被 `lib/` ignore 规则漏提交的 Admin Next utility module修复新设备初始化后 AdminNextRoutes 动态导入 500 |
| `0.63.0` | feature | `dev` | `pending` | `planet.sh` 新增空项目 `init` 和破坏性 `destroy` 入口,补齐服务运行中初始化保护、状态日志时间戳和 README 快速启动说明 |
| `0.62.0` | feature | `dev` | `pending` | Admin Next 转正并迁移旧 AntD 到 `/legacy/admin/*`,补齐采集/AI Provider/Earth/日志/BGP/告警工作台,抽出 Tactile UI 组件库并优化懒加载、Markdown 和移动端详情 |
| `0.61.0` | feature | `dev` | `pending` | Earth 图层新增 Redis 读穿缓存、防击穿、stale 兜底、payload budget、缓存状态/清理接口和采集后主动失效,降低船只与重图层演示 OOM 风险 |
| `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 配置 |

View File

@@ -6,19 +6,38 @@
"name": "planet-frontend",
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@hookform/resolvers": "^5.2.2",
"@mapbox/vector-tile": "^2.0.4",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"antd": "^5.12.5",
"autoprefixer": "^10.5.0",
"axios": "^1.6.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.10",
"echarts": "^6.0.0",
"lucide-react": "^1.16.0",
"pbf": "^4.0.1",
"pmtiles": "^4.4.1",
"postcss": "^8.5.14",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.76.0",
"react-resizable": "^3.1.3",
"react-router-dom": "^6.21.0",
"simplex-noise": "^4.0.1",
"socket.io-client": "^4.7.2",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"three": "^0.160.0",
"zod": "^4.4.3",
"zustand": "^4.4.7",
},
"devDependencies": {
@@ -78,7 +97,7 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
@@ -136,6 +155,16 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -150,13 +179,81 @@
"@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=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@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=="],
"@rc-component/context": ["@rc-component/context@1.4.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w=="],
"@rc-component/mini-decimal": ["@rc-component/mini-decimal@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.18.0" } }, "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ=="],
"@rc-component/mini-decimal": ["@rc-component/mini-decimal@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.18.0" } }, "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw=="],
"@rc-component/mutate-observer": ["@rc-component/mutate-observer@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.18.0", "classnames": "^2.3.2", "rc-util": "^5.24.4" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw=="],
@@ -224,6 +321,12 @@
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -248,17 +351,23 @@
"antd": ["antd@5.29.3", "", { "dependencies": { "@ant-design/colors": "^7.2.1", "@ant-design/cssinjs": "^1.23.0", "@ant-design/cssinjs-utils": "^1.1.3", "@ant-design/fast-color": "^2.0.6", "@ant-design/icons": "^5.6.1", "@ant-design/react-slick": "~1.1.2", "@babel/runtime": "^7.26.0", "@rc-component/color-picker": "~2.0.1", "@rc-component/mutate-observer": "^1.1.0", "@rc-component/qrcode": "~1.1.0", "@rc-component/tour": "~1.15.1", "@rc-component/trigger": "^2.3.0", "classnames": "^2.5.1", "copy-to-clipboard": "^3.3.3", "dayjs": "^1.11.11", "rc-cascader": "~3.34.0", "rc-checkbox": "~3.5.0", "rc-collapse": "~3.9.0", "rc-dialog": "~9.6.0", "rc-drawer": "~7.3.0", "rc-dropdown": "~4.2.1", "rc-field-form": "~2.7.1", "rc-image": "~7.12.0", "rc-input": "~1.8.0", "rc-input-number": "~9.5.0", "rc-mentions": "~2.20.0", "rc-menu": "~9.16.1", "rc-motion": "^2.9.5", "rc-notification": "~5.6.4", "rc-pagination": "~5.1.0", "rc-picker": "~4.11.3", "rc-progress": "~4.0.0", "rc-rate": "~2.13.1", "rc-resize-observer": "^1.4.3", "rc-segmented": "~2.7.0", "rc-select": "~14.16.8", "rc-slider": "~11.1.9", "rc-steps": "~6.0.1", "rc-switch": "~4.1.0", "rc-table": "~7.54.0", "rc-tabs": "~15.7.0", "rc-textarea": "~1.10.2", "rc-tooltip": "~6.4.0", "rc-tree": "~5.13.1", "rc-tree-select": "~5.27.0", "rc-upload": "~4.11.0", "rc-util": "^5.44.4", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="],
"axios": ["axios@1.13.4", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": "dist/cli.js" }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.30", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001767", "", {}, "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
@@ -280,9 +389,13 @@
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
"echarts": ["echarts@6.0.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.0.0" } }, "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.357", "", {}, "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g=="],
"engine.io-client": ["engine.io-client@6.6.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw=="],
@@ -306,6 +419,8 @@
"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=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -314,6 +429,8 @@
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
@@ -336,6 +453,8 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@1.16.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
@@ -346,7 +465,7 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -356,7 +475,9 @@
"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=="],
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"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=="],
@@ -396,7 +517,7 @@
"rc-pagination": ["rc-pagination@5.1.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.38.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ=="],
"rc-picker": ["rc-picker@4.11.3", "", { "dependencies": { "@babel/runtime": "^7.24.7", "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.1", "rc-overflow": "^1.3.2", "rc-resize-observer": "^1.4.0", "rc-util": "^5.43.0" }, "peerDependencies": { "date-fns": ">= 2.x", "dayjs": ">= 1.x", "luxon": ">= 3.x", "moment": ">= 2.x", "react": ">=16.9.0", "react-dom": ">=16.9.0" }, "optionalPeers": ["date-fns", "luxon", "moment"] }, "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg=="],
"rc-picker": ["rc-picker@4.11.3", "", { "dependencies": { "@babel/runtime": "^7.24.7", "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.1", "rc-overflow": "^1.3.2", "rc-resize-observer": "^1.4.0", "rc-util": "^5.43.0" }, "peerDependencies": { "date-fns": ">= 2.x", "dayjs": ">= 1.x", "luxon": ">= 3.x", "moment": ">= 2.x", "react": ">=16.9.0", "react-dom": ">=16.9.0" }, "optionalPeers": ["date-fns", "dayjs", "luxon", "moment"] }, "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg=="],
"rc-progress": ["rc-progress@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.6", "rc-util": "^5.16.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw=="],
@@ -438,16 +559,24 @@
"react-draggable": ["react-draggable@4.5.0", "", { "dependencies": { "clsx": "^2.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw=="],
"react-hook-form": ["react-hook-form@7.76.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-eKtLGgFeSgkHqQD8J59AMZ9a4uD1D83iSIzt4YlTGD7liDen5rrjcUO1rVIGd9yC1gofryjtHbv+4ny4hkLWlw=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-resizable": ["react-resizable@3.1.3", "", { "dependencies": { "prop-types": "15.x", "react-draggable": "^4.5.0" }, "peerDependencies": { "react": ">= 16.3", "react-dom": ">= 16.3" } }, "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw=="],
"react-router": ["react-router@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw=="],
"react-router-dom": ["react-router-dom@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"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=="],
@@ -470,7 +599,11 @@
"string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="],
"stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="],
"stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
"three": ["three@0.160.1", "", {}, "sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ=="],
@@ -478,12 +611,18 @@
"toggle-selection": ["toggle-selection@1.0.6", "", {}, "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ=="],
"tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
"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-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"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=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
@@ -494,8 +633,36 @@
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"zrender": ["zrender@6.0.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg=="],
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["immer"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
"@babel/helper-compilation-targets/browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"vite/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=="],
"@babel/helper-compilation-targets/browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": "dist/cli.js" }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
"@babel/helper-compilation-targets/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001767", "", {}, "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ=="],
"@babel/helper-compilation-targets/browserslist/electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
"@babel/helper-compilation-targets/browserslist/node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
}
}

19
frontend/components.json Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/admin-next/styles.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/admin-next/components",
"utils": "@/admin-next/lib/utils",
"ui": "@/admin-next/components/ui",
"lib": "@/admin-next/lib",
"hooks": "@/hooks"
}
}

View File

@@ -1,23 +1,42 @@
{
"name": "planet-frontend",
"version": "0.59.0",
"version": "0.65.2",
"private": true,
"packageManager": "bun@1",
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@hookform/resolvers": "^5.2.2",
"@mapbox/vector-tile": "^2.0.4",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"antd": "^5.12.5",
"autoprefixer": "^10.5.0",
"axios": "^1.6.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.10",
"echarts": "^6.0.0",
"lucide-react": "^1.16.0",
"pbf": "^4.0.1",
"pmtiles": "^4.4.1",
"postcss": "^8.5.14",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.76.0",
"react-resizable": "^3.1.3",
"react-router-dom": "^6.21.0",
"simplex-noise": "^4.0.1",
"socket.io-client": "^4.7.2",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"three": "^0.160.0",
"zod": "^4.4.3",
"zustand": "^4.4.7"
},
"devDependencies": {
@@ -31,6 +50,8 @@
"scripts": {
"dev": "bun ./node_modules/vite/bin/vite.js",
"build": "bun x tsc && bun ./node_modules/vite/bin/vite.js build",
"preview": "bun ./node_modules/vite/bin/vite.js preview"
"build:watch": "bun ./scripts/preview-auto.mjs --watch-only",
"preview": "bun ./node_modules/vite/bin/vite.js preview",
"preview:auto": "bun ./scripts/preview-auto.mjs"
}
}

View File

@@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -31,6 +31,14 @@
--toolbar-arc-height: var(--initial-toolbar-arc-height, calc(160px * var(--toolbar-scale)));
--toolbar-inner-arc-width: var(--initial-toolbar-inner-arc-width, calc(260px * var(--toolbar-scale)));
--toolbar-inner-arc-height: var(--initial-toolbar-inner-arc-height, calc(56px * var(--toolbar-scale)));
--toolbar-light-rgb: 91, 186, 255;
--toolbar-light-intensity: 0.8;
--toolbar-light-size-ratio: 0.85;
--toolbar-glass-opacity: 0.07;
--toolbar-glass-blur: calc(16px * var(--toolbar-scale));
--toolbar-light-blur: calc(24px * var(--toolbar-scale));
--toolbar-outer-glow-blur: calc(16px * var(--toolbar-scale));
--toolbar-active-light-scale: 1.15;
position: relative;
width: min(620px, calc(100vw - 40px));
height: var(--initial-toolbar-height, calc(200px * var(--toolbar-scale)));
@@ -74,6 +82,7 @@
left: 50%;
bottom: calc(30px * var(--toolbar-scale));
transform: translate(-50%, -50%);
--toolbar-button-size: var(--toolbar-orb-size);
}
.earth-toolbar-hub {
@@ -81,6 +90,12 @@
left: 50%;
bottom: calc(8px * var(--toolbar-scale));
transform: translateX(-50%);
--toolbar-button-size: var(--toolbar-hub-size);
}
.earth-toolbar-orb,
.earth-toolbar-hub {
isolation: isolate;
}
.earth-toolbar-orb {
@@ -105,6 +120,71 @@
pointer-events: auto;
}
.earth-toolbar-orb::before,
.earth-toolbar-hub::before,
.earth-toolbar-orb::after,
.earth-toolbar-hub::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%) scale(0.9);
transition:
opacity 0.55s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.55s cubic-bezier(0.16, 1, 0.3, 1),
filter 0.55s cubic-bezier(0.16, 1, 0.3, 1);
}
.earth-toolbar-orb::before,
.earth-toolbar-hub::before {
width: calc(var(--toolbar-button-size) * var(--toolbar-light-size-ratio));
height: calc(var(--toolbar-button-size) * var(--toolbar-light-size-ratio));
background:
radial-gradient(
circle,
rgba(var(--toolbar-light-rgb), var(--toolbar-light-intensity)) 0%,
rgba(var(--toolbar-light-rgb), 0.28) 28%,
rgba(var(--toolbar-light-rgb), 0) 72%
);
filter: blur(var(--toolbar-light-blur));
opacity: 0.12;
z-index: 0;
}
.earth-toolbar-orb::after,
.earth-toolbar-hub::after {
width: calc(var(--toolbar-button-size) + (24px * var(--toolbar-scale)));
height: calc(var(--toolbar-button-size) + (24px * var(--toolbar-scale)));
background:
radial-gradient(
circle,
rgba(var(--toolbar-light-rgb), calc(var(--toolbar-light-intensity) * 0.42)) 0%,
rgba(var(--toolbar-light-rgb), 0) 72%
);
filter: blur(var(--toolbar-outer-glow-blur));
opacity: 0;
z-index: -1;
}
.earth-toolbar-orb:hover::before,
.earth-toolbar-orb:focus-within::before,
.earth-toolbar-orb:has(> .liquid-glass-surface.active)::before,
.earth-toolbar-cluster.is-expanded .earth-toolbar-hub::before {
opacity: 1;
transform: translate(-50%, -50%) scale(var(--toolbar-active-light-scale));
filter: blur(calc(var(--toolbar-light-blur) * 0.85));
}
.earth-toolbar-orb:hover::after,
.earth-toolbar-orb:focus-within::after,
.earth-toolbar-orb:has(> .liquid-glass-surface.active)::after,
.earth-toolbar-cluster.is-expanded .earth-toolbar-hub::after {
opacity: 1;
transform: translate(-50%, -50%) scale(1.08);
}
.earth-toolbar-orb:has(#layer-action) {
display: none;
}
@@ -207,6 +287,10 @@
-moz-osx-font-smoothing: grayscale;
}
.earth-toolbar-btn {
color: var(--hud-title);
}
.liquid-glass-surface {
--elastic-x: 0px;
--elastic-y: 0px;
@@ -215,28 +299,23 @@
--btn-scale: 1;
--press-offset: 0px;
--float-offset: 0px;
--glow-opacity: 0.24;
--glow-x: 50%;
--glow-y: 22%;
position: relative;
isolation: isolate;
transform-style: preserve-3d;
transform-origin: center center;
will-change: transform, box-shadow;
overflow: hidden;
z-index: 2;
border: 1px solid rgba(255, 255, 255, 0.14);
background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.12), transparent 34%),
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.05), transparent 30%),
linear-gradient(180deg, var(--hud-surface-top), var(--hud-surface-bottom)),
rgba(8, 20, 38, 0.12);
border: 1px solid var(--hud-border);
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.085), transparent 38%),
rgba(255, 255, 255, var(--toolbar-glass-opacity));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.14),
inset 0 -1px 0 rgba(255, 255, 255, 0.05),
var(--glass-shadow),
var(--glass-glow);
backdrop-filter: blur(18px) saturate(145%);
-webkit-backdrop-filter: blur(18px) saturate(145%);
0 8px 28px rgba(0, 0, 0, 0.38),
inset 0 1.5px 2px rgba(255, 255, 255, 0.22),
inset 0 -1.5px 2px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(var(--toolbar-glass-blur, 16px)) saturate(108%) brightness(0.92);
-webkit-backdrop-filter: blur(var(--toolbar-glass-blur, 16px)) saturate(108%) brightness(0.92);
transform:
translate3d(
var(--elastic-x),
@@ -244,116 +323,123 @@
0
)
scale(var(--btn-scale));
transition:
transform 0.22s ease,
box-shadow 0.22s ease,
background 0.22s ease,
opacity 0.18s ease,
border-color 0.22s ease;
opacity 0.18s ease;
}
.liquid-glass-surface::before {
content: "";
position: absolute;
inset: 1px 1px 18px 1px;
inset: 0;
border-radius: inherit;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.04) 28%, transparent 68%);
opacity: 0.42;
radial-gradient(circle at 32% 20%, rgba(255, 255, 255, 0.13), transparent 34%),
radial-gradient(circle at 54% 54%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.035), transparent 58%);
opacity: 1;
pointer-events: none;
transform:
perspective(120px)
rotateX(calc(var(--tilt-x) * 0.7))
rotateY(calc(var(--tilt-y) * 0.7))
translate3d(calc(var(--elastic-x) * 0.22), calc(var(--elastic-y) * 0.22), 0);
transform: translate3d(calc(var(--elastic-x) * 0.08), calc(var(--elastic-y) * 0.08), 0);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.liquid-glass-surface::after {
content: "";
position: absolute;
inset: -1px;
padding: 1.35px;
inset: 0;
border-radius: inherit;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.36), rgba(168, 222, 255, 0.22) 34%, rgba(96, 175, 255, 0.16) 66%, rgba(255, 255, 255, 0.28));
opacity: 0.72;
radial-gradient(circle at 50% 50%, transparent 58%, rgba(0, 0, 0, 0.1) 100%);
box-shadow:
inset 0 0 0 0.5px rgba(255, 255, 255, 0.06);
opacity: 1;
pointer-events: none;
filter: url(#liquid-glass-distortion) blur(0.35px);
transform:
perspective(120px)
rotateX(calc(var(--tilt-x) * 0.5))
rotateY(calc(var(--tilt-y) * 0.5))
translate3d(calc(var(--elastic-x) * 0.16), calc(var(--elastic-y) * 0.16), 0);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
transition: opacity 0.18s ease, transform 0.18s ease;
transition: opacity 0.18s ease, box-shadow 0.18s ease;
}
.earth-toolbar-hub-btn.liquid-glass-surface {
background:
radial-gradient(circle at 50% 24%, rgba(255, 255, 255, 0.16), transparent 34%),
linear-gradient(180deg, rgba(28, 54, 90, 0.26), rgba(11, 24, 43, 0.22)),
rgba(10, 28, 52, 0.16);
border-color: var(--hud-border);
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.095), transparent 40%),
rgba(255, 255, 255, 0.07);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.14),
inset 0 -1px 0 rgba(255, 255, 255, 0.05),
0 16px 30px rgba(0, 0, 0, 0.24),
0 0 30px rgba(104, 181, 247, 0.18);
0 9px 30px rgba(0, 0, 0, 0.4),
inset 0 1.5px 2px rgba(255, 255, 255, 0.24),
inset 0 -1.5px 2px rgba(0, 0, 0, 0.3);
}
.liquid-glass-surface:hover {
--btn-scale: 1.04;
--btn-scale: 1.03;
--press-offset: -1px;
--glow-opacity: 0.32;
background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.14), transparent 34%),
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.08), transparent 30%),
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(128, 198, 255, 0.06)),
rgba(8, 20, 38, 0.14);
border-color: var(--hud-border-hover);
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.14), transparent 40%),
radial-gradient(circle at 52% 52%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.12), transparent 62%),
rgba(255, 255, 255, 0.085);
border-color: rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.42);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 rgba(255, 255, 255, 0.08),
0 18px 36px rgba(0, 0, 0, 0.24),
0 0 28px rgba(145, 214, 255, 0.22);
0 12px 40px rgba(0, 0, 0, 0.44),
inset 0 1.5px 3px rgba(255, 255, 255, 0.36),
inset 0 -1.5px 3px rgba(0, 0, 0, 0.2);
}
.liquid-glass-surface:hover::before {
opacity: 0.62;
transform: scale(1.01);
opacity: 1;
background:
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.2), transparent 36%),
radial-gradient(circle at 52% 52%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.14), transparent 62%);
transform: translate3d(calc(var(--elastic-x) * 0.08), calc(var(--elastic-y) * 0.08 - 1px), 0);
}
.liquid-glass-surface:hover::after {
opacity: 0.96;
transform: scale(1.01);
opacity: 1;
box-shadow:
inset 0 0 0 0.75px rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.16);
}
.liquid-glass-surface:active,
.liquid-glass-surface.is-pressed {
--btn-scale: 0.95;
--press-offset: 2px;
--glow-opacity: 0.2;
--btn-scale: 0.94;
--press-offset: 3px;
box-shadow:
0 4px 14px rgba(0, 0, 0, 0.28),
inset 0 1px 2px rgba(255, 255, 255, 0.18),
inset 0 -1px 3px rgba(0, 0, 0, 0.34);
}
.liquid-glass-surface.active {
background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.14), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(118, 200, 255, 0.08)),
rgba(11, 34, 58, 0.18);
border-color: var(--hud-border-active);
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.14), transparent 40%),
radial-gradient(circle at 52% 52%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.13), transparent 62%),
rgba(255, 255, 255, 0.09);
border-color: rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.42);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
inset 0 0 18px rgba(160, 220, 255, 0.14),
0 18px 34px rgba(0, 0, 0, 0.24),
0 0 30px rgba(145, 214, 255, 0.24);
0 12px 40px rgba(0, 0, 0, 0.46),
inset 0 1.5px 3px rgba(255, 255, 255, 0.38),
inset 0 -1.5px 3px rgba(0, 0, 0, 0.2);
}
.liquid-glass-surface.active:hover {
background:
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.16), transparent 40%),
radial-gradient(circle at 52% 52%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.17), transparent 64%),
rgba(255, 255, 255, 0.1);
box-shadow:
0 12px 42px rgba(0, 0, 0, 0.48),
inset 0 1.5px 3px rgba(255, 255, 255, 0.42),
inset 0 -1.5px 3px rgba(0, 0, 0, 0.18);
}
.earth-toolbar-cluster.is-expanded .earth-toolbar-hub-btn.liquid-glass-surface {
background:
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.15), transparent 42%),
radial-gradient(circle at 52% 52%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.15), transparent 64%),
rgba(255, 255, 255, 0.095);
border-color: rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.44);
box-shadow:
0 12px 42px rgba(0, 0, 0, 0.48),
inset 0 1.5px 3px rgba(255, 255, 255, 0.4),
inset 0 -1.5px 3px rgba(0, 0, 0, 0.18);
}
.earth-rotate-toggle .icon-play,

View File

@@ -364,14 +364,6 @@
<span class="tooltip earth-toolbar-tooltip">自动旋转</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="3" style="--orb-delay: 0.36s;">
<button id="toggle-tv" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="新闻直播">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">live_tv</span>
</span>
<span class="tooltip earth-toolbar-tooltip">打开媒体面板</span>
</button>
</div>
<div class="earth-toolbar-orb" data-orb-index="4" style="--orb-delay: 0.54s;">
<button id="reload-data" class="floating-btn liquid-glass-surface earth-toolbar-btn" title="重新加载数据">
<span class="icon" aria-hidden="true">
@@ -389,7 +381,7 @@
</button>
<div id="zoom-toolbar" class="earth-stack-toolbar earth-zoom-toolbar">
<button id="zoom-in" class="liquid-glass-surface earth-zoom-btn" title="放大" aria-label="放大"><span aria-hidden="true">+</span></button>
<span id="zoom-value" class="liquid-glass-surface earth-zoom-value" title="重置缩放到100%">100%<span class="tooltip earth-toolbar-tooltip">重置缩放到100%</span></span>
<span id="zoom-value" class="liquid-glass-surface earth-zoom-value" title="重置缩放到100%">100%</span>
<button id="zoom-out" class="liquid-glass-surface earth-zoom-btn" title="缩小" aria-label="缩小"><span aria-hidden="true"></span></button>
</div>
</div>
@@ -840,7 +832,7 @@
<span class="earth-mobile-page-kicker">Motion Capture</span>
<span class="earth-mobile-page-summary">查看浏览器摄像头画面、骨架连线和当前匹配动作</span>
</div>
<div id="mobile-motion-debug-mount" class="earth-mobile-motion-debug-mount"></div>
<div class="earth-mobile-motion-empty">动捕调试画面已并入设置里的动捕模式。</div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="settings">
@@ -849,22 +841,53 @@
<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>
<button type="button" class="earth-mobile-settings-tab" data-settings-tab="about" 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>
<span class="earth-mobile-settings-subtitle">旋转、巡航和动捕是互斥运行模式</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择旋转模式">
<button type="button" class="earth-mobile-settings-pill is-active" data-rotation-mode="rotate" aria-pressed="true">旋转模式</button>
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="cruise" aria-pressed="false">巡航模式</button>
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="motion" aria-pressed="false">动捕模式</button>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择旋转模式">
<button type="button" class="earth-mobile-settings-pill is-active" data-rotation-mode="rotate" aria-pressed="true">旋转模式</button>
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="cruise" aria-pressed="false">巡航模式</button>
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="motion" aria-pressed="false">动捕模式</button>
</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked" data-runtime-mode-section="rotate">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">巡航模块</span>
<span class="earth-mobile-settings-label">旋转转速</span>
<span class="earth-mobile-settings-subtitle">默认速率 1x</span>
</div>
<div class="earth-mobile-settings-slider-row">
<input
class="earth-mobile-settings-slider"
type="range"
min="0.0001"
max="0.0015"
step="0.00005"
value="0.00050"
data-auto-rotation-speed-slider
aria-label="移动端调整旋转转速"
>
<span class="earth-mobile-settings-slider-value" data-auto-rotation-speed-value>1.0x</span>
<button type="button" class="earth-settings-reload-action" data-auto-rotation-speed-reset aria-label="重置旋转转速为1x" title="重置为1x">
<span class="material-symbols-rounded">restart_alt</span>
</button>
</div>
</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">巡航模块</span>
<span class="earth-mobile-settings-subtitle">选择哪些可交互图层参与巡航队列。</span>
</div>
<div class="earth-mobile-settings-chip-group" role="group" aria-label="移动端选择巡航模块">
@@ -876,9 +899,94 @@
<button type="button" class="earth-mobile-settings-chip" data-cruise-module-toggle="satellites" aria-pressed="false">卫星</button>
</div>
</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">巡航队列</span>
<span class="earth-mobile-settings-subtitle">控制巡航项按原始顺序、大区轮转或随机播放。</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择巡航队列模式">
<button type="button" class="earth-mobile-settings-pill is-active" data-cruise-queue-mode="default" aria-pressed="true">默认</button>
<button type="button" class="earth-mobile-settings-pill" data-cruise-queue-mode="region" aria-pressed="false">按大区</button>
<button type="button" class="earth-mobile-settings-pill" data-cruise-queue-mode="random" aria-pressed="false">随机</button>
</div>
<div class="earth-cruise-region-order earth-cruise-region-order--mobile" data-cruise-region-order-shell hidden>
<div class="earth-cruise-region-order-list" data-cruise-region-order-list></div>
</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-subsection-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-daynight-toggle checked>
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<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 class="earth-mobile-settings-subsection-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>
@@ -889,17 +997,6 @@
<button type="button" class="earth-mobile-settings-pill is-active" data-satellite-display-style="ground_footprint" aria-pressed="true">真实地表覆盖</button>
</div>
</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">悬停提示</span>
<span class="earth-mobile-settings-subtitle">控制鼠标悬停地表时显示国家、位置或完整信息</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择悬停提示内容">
<button type="button" class="earth-mobile-settings-pill" data-surface-hover-info-mode="country" aria-pressed="false">国家</button>
<button type="button" class="earth-mobile-settings-pill" data-surface-hover-info-mode="position" aria-pressed="false">位置</button>
<button type="button" class="earth-mobile-settings-pill is-active" data-surface-hover-info-mode="full" aria-pressed="true">完整</button>
</div>
</div>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">卫星呼吸闪烁</span>
@@ -930,6 +1027,18 @@
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<div class="earth-mobile-settings-subsection-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-segmented" role="group" aria-label="移动端选择悬停提示内容">
<button type="button" class="earth-mobile-settings-pill" data-surface-hover-info-mode="country" aria-pressed="false">国家</button>
<button type="button" class="earth-mobile-settings-pill" data-surface-hover-info-mode="position" aria-pressed="false">位置</button>
<button type="button" class="earth-mobile-settings-pill is-active" data-surface-hover-info-mode="full" aria-pressed="true">完整</button>
</div>
</div>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">低缩放圆点</span>
@@ -940,20 +1049,56 @@
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<label class="earth-mobile-settings-card">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">日夜模式</span>
<span class="earth-mobile-settings-subtitle">按真实太阳位置区分地球昼夜明暗</span>
</div>
<span class="earth-mobile-settings-switch">
<input type="checkbox" data-daynight-toggle checked>
<span class="earth-mobile-settings-switch-track"></span>
</span>
</label>
<label
class="earth-mobile-settings-card"
data-gatekeeper-permission="earth.motion_debug"
>
</div>
<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>
@@ -976,55 +1121,55 @@
<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>
</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-panel-heading">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">键盘控制</span>
<span class="earth-mobile-settings-subtitle">点击按键后按下新的快捷键Esc 取消录入</span>
</div>
<button class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button" data-shortcut-reset-all>恢复默认快捷键</button>
</div>
<div class="earth-shortcut-list" data-shortcut-list></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>
<a class="earth-mobile-action-btn" href="/admin" target="_blank" rel="noreferrer noopener">打开 Admin</a>
</div>
</div>
<div class="earth-mobile-settings-group" data-settings-tab-panel="about" hidden>
<div class="earth-mobile-settings-title">关于</div>
<div class="earth-about-card earth-about-card--mobile">
<div class="earth-about-logo-frame">
<img class="earth-about-logo" src="./assets/brand/lim-logo.png" alt="浙江大学临空智能媒体研究院 Logo">
</div>
<div class="earth-about-heading">
<span class="earth-about-kicker">About</span>
<strong>智能星球计划</strong>
<span>v0.61.0</span>
</div>
<p class="earth-about-copy">
面向临空场景下的智能媒体研究、全球态势感知与多源开放数据巡航,提供可视化观测、事件聚合与交互式探索能力。
</p>
<div class="earth-about-meta">
<span>出品方</span>
<strong>浙江大学临空智能媒体研究院</strong>
</div>
<div class="earth-about-meta">
<span>策划人</span>
<strong>黄柳青</strong>
</div>
<div class="earth-about-meta">
<span>产品兼开发者</span>
<strong>钱坤、张鸽、齐鹏</strong>
</div>
</div>
</div>
</div>
</section>
<section class="earth-mobile-drawer-slot" data-drawer-slot="details">
@@ -1100,9 +1245,18 @@
<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>
<button type="button" class="earth-settings-tab" data-settings-tab="about" 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">
@@ -1136,6 +1290,28 @@
</button>
</div>
</div>
<div class="earth-settings-item earth-settings-item--stacked" data-runtime-mode-section="rotate">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">旋转转速</span>
<span class="earth-settings-item-subtitle">默认速率 1x调高后自动旋转更快。</span>
</div>
<div class="earth-settings-slider-row">
<input
class="earth-settings-slider"
type="range"
min="0.0001"
max="0.0015"
step="0.00005"
value="0.00050"
data-auto-rotation-speed-slider
aria-label="调整旋转转速"
>
<span class="earth-settings-slider-value" data-auto-rotation-speed-value>1.0x</span>
<button type="button" class="earth-settings-reload-action" data-auto-rotation-speed-reset aria-label="重置旋转转速为1x" title="重置为1x">
<span class="material-symbols-rounded">restart_alt</span>
</button>
</div>
</div>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">巡航模块</span>
@@ -1192,11 +1368,96 @@
</button>
</div>
</div>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">巡航队列</span>
<span class="earth-settings-item-subtitle">控制巡航项按原始顺序、大区轮转或随机洗牌播放。</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择巡航队列模式">
<button type="button" class="earth-settings-segmented-btn is-active" data-cruise-queue-mode="default" aria-pressed="true">默认</button>
<button type="button" class="earth-settings-segmented-btn" data-cruise-queue-mode="region" aria-pressed="false">按大区</button>
<button type="button" class="earth-settings-segmented-btn" data-cruise-queue-mode="random" aria-pressed="false">随机</button>
</div>
<div class="earth-cruise-region-order" data-cruise-region-order-shell hidden>
<div class="earth-cruise-region-order-list" data-cruise-region-order-list></div>
</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="display" hidden>
<div class="earth-settings-section-title">显示</div>
<div class="earth-settings-list">
<div class="earth-settings-subsection-title">地球与地表</div>
<label class="earth-settings-item" for="toggle-daynight">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">日夜模式</span>
<span class="earth-settings-item-subtitle">按真实太阳位置区分地球昼夜明暗,关闭后全球均匀照亮</span>
</div>
<span class="earth-settings-switch">
<input id="toggle-daynight" type="checkbox" checked>
<span class="earth-settings-switch-track"></span>
</span>
</label>
<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="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 class="earth-settings-subsection-title">卫星</div>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">卫星显示风格</span>
@@ -1221,38 +1482,6 @@
</button>
</div>
</div>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">悬停提示</span>
<span class="earth-settings-item-subtitle">控制鼠标悬停地表时显示国家、位置或完整信息。</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择悬停提示内容">
<button
type="button"
class="earth-settings-segmented-btn"
data-surface-hover-info-mode="country"
aria-pressed="false"
>
国家
</button>
<button
type="button"
class="earth-settings-segmented-btn"
data-surface-hover-info-mode="position"
aria-pressed="false"
>
位置
</button>
<button
type="button"
class="earth-settings-segmented-btn is-active"
data-surface-hover-info-mode="full"
aria-pressed="true"
>
完整
</button>
</div>
</div>
<label class="earth-settings-item" for="toggle-satellite-idle-breathing">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">卫星呼吸闪烁</span>
@@ -1283,6 +1512,39 @@
<span class="earth-settings-switch-track"></span>
</span>
</label>
<div class="earth-settings-subsection-title">交互提示</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-segmented" role="group" aria-label="选择悬停提示内容">
<button
type="button"
class="earth-settings-segmented-btn"
data-surface-hover-info-mode="country"
aria-pressed="false"
>
国家
</button>
<button
type="button"
class="earth-settings-segmented-btn"
data-surface-hover-info-mode="position"
aria-pressed="false"
>
位置
</button>
<button
type="button"
class="earth-settings-segmented-btn is-active"
data-surface-hover-info-mode="full"
aria-pressed="true"
>
完整
</button>
</div>
</div>
<label class="earth-settings-item" for="toggle-interactable-compact-dots">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">低缩放圆点</span>
@@ -1293,43 +1555,11 @@
<span class="earth-settings-switch-track"></span>
</span>
</label>
<label class="earth-settings-item" for="toggle-daynight">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">日夜模式</span>
<span class="earth-settings-item-subtitle">按真实太阳位置区分地球昼夜明暗,关闭后全球均匀照亮</span>
</div>
<span class="earth-settings-switch">
<input id="toggle-daynight" type="checkbox" checked>
<span class="earth-settings-switch-track"></span>
</span>
</label>
<label
class="earth-settings-item"
for="toggle-motion-debug"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-settings-copy">
<span class="earth-settings-item-title">动捕调试模式</span>
<span class="earth-settings-item-subtitle">打开骨架连线面板;未匹配为红线,匹配动作后变绿</span>
</div>
<span class="earth-settings-switch">
<input id="toggle-motion-debug" type="checkbox" data-motion-debug-toggle>
<span class="earth-settings-switch-track"></span>
</span>
</label>
<div
class="earth-settings-item earth-settings-item--stacked"
data-gatekeeper-permission="earth.motion_debug"
>
<div class="earth-settings-copy">
<span class="earth-settings-item-title">动捕输入源</span>
<span class="earth-settings-item-subtitle">浏览器摄像头适合网页/SaaSMotion 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" 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>
@@ -1372,81 +1602,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">浏览器摄像头适合网页/SaaSMotion 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>
</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>
</section>
<section class="earth-settings-section">
<div class="earth-settings-section-title">国界精度</div>
<div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title" id="boundary-precision-status">国界精度</span>
<span class="earth-settings-item-subtitle" id="boundary-precision-detail">正在读取高清国界状态...</span>
</div>
<div id="boundary-precision-progress-wrap" class="earth-boundary-progress" hidden>
<div class="earth-boundary-progress__track">
<div id="boundary-precision-progress-bar" class="earth-boundary-progress__bar"></div>
<div class="earth-shortcut-panel-heading">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">键盘控制</span>
<span class="earth-settings-item-subtitle">点击按键后按下新的快捷键Esc 取消录入,冲突快捷键不会保存</span>
</div>
<span id="boundary-precision-progress-value" class="earth-boundary-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" aria-pressed="true">低精</button>
<button id="boundary-precision-build" type="button" class="earth-settings-segmented-btn" aria-pressed="false">高精</button>
</div>
<button id="boundary-precision-rebuild" type="button" class="earth-settings-reload-action" aria-label="重新获取并构建高清国界" title="重新获取并构建" hidden disabled>
<span class="material-symbols-rounded">refresh</span>
</button>
<button class="earth-settings-reload-action earth-settings-reload-action--text" type="button" data-shortcut-reset-all>恢复默认</button>
</div>
<div class="earth-shortcut-list" data-shortcut-list></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
@@ -1466,6 +1669,38 @@
</a>
</div>
</section>
<section class="earth-settings-section" data-settings-tab-panel="about" hidden>
<div class="earth-settings-section-title">关于</div>
<div class="earth-settings-list">
<div class="earth-settings-item earth-settings-item--stacked earth-about-card">
<div class="earth-about-logo-frame">
<img class="earth-about-logo" src="./assets/brand/lim-logo.png" alt="浙江大学临空智能媒体研究院 Logo">
</div>
<div class="earth-about-heading">
<span class="earth-about-kicker">About</span>
<strong>智能星球计划</strong>
<span>v0.61.0</span>
</div>
<p class="earth-about-copy">
面向临空场景下的智能媒体研究、全球态势感知与多源开放数据巡航,提供可视化观测、事件聚合与交互式探索能力。
</p>
<div class="earth-about-grid">
<div class="earth-about-meta">
<span>出品方</span>
<strong>浙江大学临空智能媒体研究院</strong>
</div>
<div class="earth-about-meta">
<span>策划人</span>
<strong>黄柳青</strong>
</div>
<div class="earth-about-meta">
<span>产品兼开发者</span>
<strong>钱坤、张鸽、齐鹏</strong>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>

View File

@@ -0,0 +1,96 @@
const EARTH_ABOUT_API = "/api/v1/earth/about";
const DEFAULT_ABOUT = {
logo_src: "./assets/brand/lim-logo.png",
kicker: "About",
title: "智能星球计划",
version: "v0.64.0",
description:
"面向临空场景下的智能媒体研究、全球态势感知与多源开放数据巡航,提供可视化观测、事件聚合与交互式探索能力。",
meta: [
{ label: "出品方", value: "浙江大学临空智能媒体研究院" },
{ label: "策划人", value: "黄柳青" },
{ label: "产品兼开发者", value: "钱坤、张鸽、齐鹏" },
],
};
function escapeHtml(value = "") {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escapeAttribute(value = "") {
return escapeHtml(value);
}
function normalizeAbout(payload) {
const about = payload && typeof payload === "object" ? payload : {};
const meta = Array.isArray(about.meta) ? about.meta : DEFAULT_ABOUT.meta;
return {
...DEFAULT_ABOUT,
...about,
logo_src: about.logo_src || DEFAULT_ABOUT.logo_src,
kicker: about.kicker || DEFAULT_ABOUT.kicker,
title: about.title || DEFAULT_ABOUT.title,
version: about.version || DEFAULT_ABOUT.version,
description: about.description || DEFAULT_ABOUT.description,
meta: meta
.map((item) => ({
label: String(item?.label || "").trim(),
value: String(item?.value || "").trim(),
}))
.filter((item) => item.label || item.value),
};
}
function renderAboutCard(about, isMobile = false) {
const metaClass = isMobile ? "earth-about-meta-list" : "earth-about-grid";
const metaMarkup = about.meta
.map(
(item) => `
<div class="earth-about-meta">
<span>${escapeHtml(item.label)}</span>
<strong>${escapeHtml(item.value)}</strong>
</div>
`,
)
.join("");
return `
<div class="earth-about-logo-frame">
<img class="earth-about-logo" src="${escapeAttribute(about.logo_src)}" alt="${escapeAttribute(about.title)} Logo">
</div>
<div class="earth-about-heading">
<span class="earth-about-kicker">${escapeHtml(about.kicker)}</span>
<strong>${escapeHtml(about.title)}</strong>
<span>${escapeHtml(about.version)}</span>
</div>
<p class="earth-about-copy">${escapeHtml(about.description)}</p>
<div class="${metaClass}">
${metaMarkup}
</div>
`.trim();
}
function applyAboutConfig(about) {
document.querySelectorAll(".earth-about-card").forEach((card) => {
const isMobile = card.classList.contains("earth-about-card--mobile");
card.innerHTML = renderAboutCard(about, isMobile);
});
}
export async function initEarthAbout() {
applyAboutConfig(normalizeAbout(DEFAULT_ABOUT));
try {
const response = await fetch(EARTH_ABOUT_API, { cache: "no-store" });
if (!response.ok) throw new Error(`Earth about request failed: ${response.status}`);
const payload = await response.json();
applyAboutConfig(normalizeAbout(payload?.about));
} catch (error) {
console.warn("Earth about config unavailable, using defaults.", error);
}
}

Some files were not shown because too many files have changed in this diff Show More