diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..d683496a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +** + +!pyproject.toml +!uv.lock +!aiprovider/ +!aiprovider/** + +aiprovider/.env +aiprovider/.env.* +!aiprovider/.env.example +**/__pycache__/ +**/*.pyc +**/*.pyo diff --git a/VERSION b/VERSION index b51b5439..bcce5d06 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.44.2 +0.45.0 diff --git a/aiprovider/Dockerfile b/aiprovider/Dockerfile index 4c99f3e7..b3651008 100644 --- a/aiprovider/Dockerfile +++ b/aiprovider/Dockerfile @@ -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 @@ -18,9 +20,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && 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 \ + uv sync --frozen --no-dev -COPY . /app +COPY aiprovider /app/aiprovider EXPOSE 8010 diff --git a/backend/app/api/v1/datasources.py b/backend/app/api/v1/datasources.py index 653117b6..16d0239d 100644 --- a/backend/app/api/v1/datasources.py +++ b/backend/app/api/v1/datasources.py @@ -398,6 +398,11 @@ async def list_datasources( "task_id": running_task.id if running_task else None, "progress": running_task.progress if running_task else None, "phase": running_task.phase if running_task else None, + "phase_progress": running_task.phase_progress if running_task else None, + "phase_message": running_task.phase_message if running_task else None, + "phase_current": running_task.phase_current if running_task else None, + "phase_total": running_task.phase_total if running_task else None, + "phase_unit": running_task.phase_unit if running_task else None, "records_processed": running_task.records_processed if running_task else None, "total_records": running_task.total_records if running_task else None, } @@ -626,6 +631,11 @@ async def trigger_datasource( "message": "当前采集任务尚未完成,重新触发会丢失本次未完成进度。是否强制重新采集?", "task_id": running_task.id, "phase": running_task.phase, + "phase_progress": running_task.phase_progress, + "phase_message": running_task.phase_message, + "phase_current": running_task.phase_current, + "phase_total": running_task.phase_total, + "phase_unit": running_task.phase_unit, "progress": running_task.progress, "records_processed": running_task.records_processed, "total_records": running_task.total_records, @@ -709,13 +719,29 @@ async def get_task_status( task = await get_running_task(db, datasource.id) if not task: - return {"is_running": False, "task_id": None, "progress": None, "phase": None, "status": "idle"} + return { + "is_running": False, + "task_id": None, + "progress": None, + "phase": None, + "phase_progress": None, + "phase_message": None, + "phase_current": None, + "phase_total": None, + "phase_unit": None, + "status": "idle", + } return { "is_running": task.status == "running", "task_id": task.id, "progress": task.progress, "phase": task.phase, + "phase_progress": task.phase_progress, + "phase_message": task.phase_message, + "phase_current": task.phase_current, + "phase_total": task.phase_total, + "phase_unit": task.phase_unit, "records_processed": task.records_processed, "total_records": task.total_records, "status": task.status, diff --git a/backend/app/api/v1/tasks.py b/backend/app/api/v1/tasks.py index 8dfb7af5..2ad29304 100644 --- a/backend/app/api/v1/tasks.py +++ b/backend/app/api/v1/tasks.py @@ -27,7 +27,9 @@ async def list_tasks( offset = (page - 1) * page_size query = """ SELECT ct.id, ct.datasource_id, ds.name as datasource_name, ct.status, - ct.started_at, ct.completed_at, ct.records_processed, ct.error_message + ct.started_at, ct.completed_at, ct.records_processed, ct.error_message, + ct.phase, ct.phase_progress, ct.phase_message, ct.phase_current, + ct.phase_total, ct.phase_unit, ct.total_records, ct.progress FROM collection_tasks ct JOIN data_sources ds ON ct.datasource_id = ds.id WHERE 1=1 @@ -66,6 +68,14 @@ async def list_tasks( "completed_at": to_iso8601_utc(t[5]), "records_processed": t[6], "error_message": t[7], + "phase": t[8], + "phase_progress": t[9], + "phase_message": t[10], + "phase_current": t[11], + "phase_total": t[12], + "phase_unit": t[13], + "total_records": t[14], + "progress": t[15], } for t in tasks ], @@ -81,7 +91,9 @@ async def get_task( result = await db.execute( text(""" SELECT ct.id, ct.datasource_id, ds.name as datasource_name, ct.status, - ct.started_at, ct.completed_at, ct.records_processed, ct.error_message + ct.started_at, ct.completed_at, ct.records_processed, ct.error_message, + ct.phase, ct.phase_progress, ct.phase_message, ct.phase_current, + ct.phase_total, ct.phase_unit, ct.total_records, ct.progress FROM collection_tasks ct JOIN data_sources ds ON ct.datasource_id = ds.id WHERE ct.id = :id @@ -105,6 +117,14 @@ async def get_task( "completed_at": to_iso8601_utc(task[5]), "records_processed": task[6], "error_message": task[7], + "phase": task[8], + "phase_progress": task[9], + "phase_message": task[10], + "phase_current": task[11], + "phase_total": task[12], + "phase_unit": task[13], + "total_records": task[14], + "progress": task[15], } diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 29484719..33f8f52a 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -146,7 +146,12 @@ async def init_db(): text( """ ALTER TABLE collection_tasks - ADD COLUMN IF NOT EXISTS phase VARCHAR(30) DEFAULT 'queued' + ADD COLUMN IF NOT EXISTS phase VARCHAR(30) DEFAULT 'queued', + ADD COLUMN IF NOT EXISTS phase_progress DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS phase_message VARCHAR(255), + ADD COLUMN IF NOT EXISTS phase_current BIGINT, + ADD COLUMN IF NOT EXISTS phase_total BIGINT, + ADD COLUMN IF NOT EXISTS phase_unit VARCHAR(30) """ ) ) diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 12d858c2..0e29c299 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -1,6 +1,6 @@ """Collection Task model""" -from sqlalchemy import Column, DateTime, Integer, String, Text, Float +from sqlalchemy import BigInteger, Column, DateTime, Integer, String, Text, Float from sqlalchemy.sql import func from app.db.session import Base @@ -13,6 +13,11 @@ class CollectionTask(Base): datasource_id = Column(Integer, nullable=False, index=True) status = Column(String(20), nullable=False) # pending, running, success, failed, cancelled phase = Column(String(30), default="queued") + phase_progress = Column(Float) + phase_message = Column(String(255)) + phase_current = Column(BigInteger) + phase_total = Column(BigInteger) + phase_unit = Column(String(30)) started_at = Column(DateTime(timezone=True)) completed_at = Column(DateTime(timezone=True)) records_processed = Column(Integer, default=0) diff --git a/backend/app/services/collectors/base.py b/backend/app/services/collectors/base.py index dcdbe36a..f1d0285d 100644 --- a/backend/app/services/collectors/base.py +++ b/backend/app/services/collectors/base.py @@ -54,6 +54,11 @@ class BaseCollector(ABC): "task_id": self._current_task.id, "status": self._current_task.status, "phase": self._current_task.phase, + "phase_progress": self._current_task.phase_progress, + "phase_message": self._current_task.phase_message, + "phase_current": self._current_task.phase_current, + "phase_total": self._current_task.phase_total, + "phase_unit": self._current_task.phase_unit, "progress": progress, "records_processed": self._current_task.records_processed, "total_records": self._current_task.total_records, @@ -80,12 +85,52 @@ class BaseCollector(ABC): await self._publish_task_update(force=force) - async def set_phase(self, phase: str): + async def set_phase(self, phase: str, *, message: str | None = None, reset_progress: bool = True): if self._current_task and self._db_session: self._current_task.phase = phase + self._current_task.phase_message = message + if reset_progress: + self._current_task.phase_progress = None + self._current_task.phase_current = None + self._current_task.phase_total = None + self._current_task.phase_unit = None await self._db_session.commit() await self._publish_task_update(force=True) + async def update_phase_progress( + self, + *, + current: int | None = None, + total: int | None = None, + unit: str | None = None, + message: str | None = None, + progress: float | None = None, + commit: bool = False, + force: bool = False, + ): + """Update progress for the current phase without changing task totals.""" + if not self._current_task or not self._db_session: + return + + if progress is None and current is not None and total and total > 0: + progress = (current / total) * 100 + + if progress is not None: + self._current_task.phase_progress = max(0.0, min(float(progress), 100.0)) + if current is not None: + self._current_task.phase_current = max(0, int(current)) + if total is not None: + self._current_task.phase_total = max(0, int(total)) + if unit is not None: + self._current_task.phase_unit = unit + if message is not None: + self._current_task.phase_message = message + + if commit: + await self._db_session.commit() + + await self._publish_task_update(force=force) + @abstractmethod async def fetch(self) -> List[Dict[str, Any]]: """Fetch raw data from source""" @@ -251,7 +296,7 @@ class BaseCollector(ABC): await self._publish_task_update(force=True) try: - await self.set_phase("fetching") + await self.set_phase("fetching", message="正在拉取原始数据") raw_data = await self.fetch() task.total_records = len(raw_data) await db.commit() @@ -260,15 +305,20 @@ class BaseCollector(ABC): if self.fail_on_empty and not raw_data: raise RuntimeError(f"Collector {self.name} returned no data") - await self.set_phase("transforming") + await self.set_phase("transforming", message="正在转换采集数据") data = self.transform(raw_data) snapshot_id = await self._create_snapshot(db, task_id, data, start_time) - await self.set_phase("saving") + await self.set_phase("saving", message="正在保存采集数据") records_count = await self._save_data(db, data, task_id=task_id, snapshot_id=snapshot_id) task.status = "success" task.phase = "completed" + task.phase_progress = 100.0 + task.phase_message = "采集完成" + task.phase_current = records_count + task.phase_total = records_count + task.phase_unit = "records" task.records_processed = records_count task.progress = 100.0 task.completed_at = datetime.now(UTC) @@ -285,6 +335,7 @@ class BaseCollector(ABC): await db.rollback() task.status = "cancelled" task.phase = "cancelled" + task.phase_message = "采集已取消" task.error_message = "Collection cancelled by operator and rolled back" task.completed_at = datetime.now(UTC) if snapshot_id is not None: @@ -301,6 +352,7 @@ class BaseCollector(ABC): await db.rollback() task.status = "failed" task.phase = "failed" + task.phase_message = str(e) task.error_message = str(e) task.completed_at = datetime.now(UTC) if snapshot_id is not None: diff --git a/backend/app/services/collectors/iptoasn.py b/backend/app/services/collectors/iptoasn.py index 665aad94..24f3e17d 100644 --- a/backend/app/services/collectors/iptoasn.py +++ b/backend/app/services/collectors/iptoasn.py @@ -108,6 +108,11 @@ class IPtoASNPrefixGeoCollector(BaseCollector): self._current_task.total_records = total_expected self._current_task.records_processed = 0 self._current_task.progress = 0.0 + self._current_task.phase_progress = 0.0 + self._current_task.phase_message = "正在下载 IPtoASN 数据" + self._current_task.phase_current = 0 + self._current_task.phase_total = total_expected + self._current_task.phase_unit = "bytes" await self._db_session.commit() await self._publish_task_update(force=True) @@ -135,7 +140,14 @@ class IPtoASNPrefixGeoCollector(BaseCollector): return last_emit["value"] = aggregated last_emit["t"] = now - await self.update_progress(min(aggregated, total_expected), commit=True) + current = min(aggregated, total_expected) + await self.update_phase_progress( + current=current, + total=total_expected, + unit="bytes", + message="正在下载 IPtoASN 数据", + ) + await self.update_progress(current, commit=True) batches = await asyncio.gather( *( @@ -148,6 +160,12 @@ class IPtoASNPrefixGeoCollector(BaseCollector): ) ) if total_expected > 0: + await self.update_phase_progress( + current=total_expected, + total=total_expected, + unit="bytes", + message="IPtoASN 数据下载完成", + ) await self.update_progress(total_expected, commit=True, force=True) rows: list[dict[str, Any]] = [] diff --git a/backend/app/services/collectors/nro_delegated.py b/backend/app/services/collectors/nro_delegated.py index 52967ad1..207880ee 100644 --- a/backend/app/services/collectors/nro_delegated.py +++ b/backend/app/services/collectors/nro_delegated.py @@ -39,12 +39,23 @@ class NRODelegatedPrefixGeoCollector(BaseCollector): self._current_task.total_records = total_expected self._current_task.records_processed = 0 self._current_task.progress = 0.0 + self._current_task.phase_progress = 0.0 + self._current_task.phase_message = "正在下载 NRO delegated 数据" + self._current_task.phase_current = 0 + self._current_task.phase_total = total_expected + self._current_task.phase_unit = "bytes" await self._db_session.commit() await self._publish_task_update(force=True) async def on_progress(downloaded: int, total: int | None) -> None: if not total or total <= 0: return + await self.update_phase_progress( + current=min(downloaded, total), + total=total, + unit="bytes", + message="正在下载 NRO delegated 数据", + ) await self.update_progress(min(downloaded, total), commit=True) body_path = await self._downloader.download_file( diff --git a/backend/app/services/collectors/opengeofeed.py b/backend/app/services/collectors/opengeofeed.py index bb91f4fb..80e1c3b4 100644 --- a/backend/app/services/collectors/opengeofeed.py +++ b/backend/app/services/collectors/opengeofeed.py @@ -40,12 +40,23 @@ class OpenGeoFeedPrefixGeoCollector(BaseCollector): self._current_task.total_records = total_expected self._current_task.records_processed = 0 self._current_task.progress = 0.0 + self._current_task.phase_progress = 0.0 + self._current_task.phase_message = "正在下载 OpenGeoFeed 数据" + self._current_task.phase_current = 0 + self._current_task.phase_total = total_expected + self._current_task.phase_unit = "bytes" await self._db_session.commit() await self._publish_task_update(force=True) async def on_progress(downloaded: int, total: int | None) -> None: if not total or total <= 0: return + await self.update_phase_progress( + current=min(downloaded, total), + total=total, + unit="bytes", + message="正在下载 OpenGeoFeed 数据", + ) await self.update_progress(min(downloaded, total), commit=True) body_path = await self._downloader.download_file( diff --git a/backend/tests/test_collectors.py b/backend/tests/test_collectors.py index 149f0b5c..e6cdfe76 100644 --- a/backend/tests/test_collectors.py +++ b/backend/tests/test_collectors.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from app.services.collectors.top500 import TOP500Collector from app.services.collectors.base import BaseCollector, HTTPCollector +from app.models.task import CollectionTask class TestBaseCollector: @@ -19,6 +20,31 @@ class TestBaseCollector: assert collector.module == "L1" assert collector.frequency_hours == 4 + @pytest.mark.asyncio + async def test_update_phase_progress_tracks_phase_fields(self, mock_db_session): + """Test phase-level progress updates independently from record totals""" + collector = TOP500Collector() + task = CollectionTask(datasource_id=1, status="running", phase="fetching") + collector._current_task = task + collector._db_session = mock_db_session + + with patch.object(collector, "_publish_task_update", new=AsyncMock()) as publish: + await collector.update_phase_progress( + current=512, + total=1024, + unit="bytes", + message="Downloading dataset", + commit=True, + ) + + assert task.phase_progress == 50.0 + assert task.phase_current == 512 + assert task.phase_total == 1024 + assert task.phase_unit == "bytes" + assert task.phase_message == "Downloading dataset" + mock_db_session.commit.assert_awaited_once() + publish.assert_awaited_once() + class TestTOP500Collector: """Tests for TOP500Collector""" diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 33c4aae9..d63846ab 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -121,6 +121,25 @@ class TestCollectionTaskModel: ) assert task.records_processed == 100 + def test_task_with_phase_progress(self): + """Test collection task phase-level progress fields""" + task = CollectionTask( + datasource_id=1, + status="running", + phase="fetching", + phase_progress=42.5, + phase_message="Downloading dataset", + phase_current=1024, + phase_total=4096, + phase_unit="bytes", + ) + assert task.phase == "fetching" + assert task.phase_progress == 42.5 + assert task.phase_message == "Downloading dataset" + assert task.phase_current == 1024 + assert task.phase_total == 4096 + assert task.phase_unit == "bytes" + def test_task_error_message(self): """Test collection task with error message""" task = CollectionTask( diff --git a/database_schema.sql b/database_schema.sql index eccef58d..17b58bd4 100644 --- a/database_schema.sql +++ b/database_schema.sql @@ -86,6 +86,12 @@ CREATE TABLE collection_tasks ( id BIGSERIAL PRIMARY KEY, datasource_id INTEGER NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE, status task_status NOT NULL DEFAULT 'pending', + phase VARCHAR(30) DEFAULT 'queued', + phase_progress FLOAT, + phase_message VARCHAR(255), + phase_current BIGINT, + phase_total BIGINT, + phase_unit VARCHAR(30), started_at TIMESTAMP WITH TIME ZONE, completed_at TIMESTAMP WITH TIME ZONE, records_processed INTEGER DEFAULT 0, diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml index 55c91b76..c8c2e3fd 100644 --- a/docker-compose.simple.yml +++ b/docker-compose.simple.yml @@ -8,6 +8,9 @@ services: args: PYTHON_IMAGE: ${PYTHON_IMAGE:-python:3.14-slim} UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:latest} + env_file: + - ./aiprovider/.env + - ${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-./aiprovider/.env} container_name: planet_aiprovider ports: - "8010:8010" diff --git a/docker-compose.yml b/docker-compose.yml index ec0b4d1d..3ee9f9eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:latest} env_file: - ./aiprovider/.env + - ${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-./aiprovider/.env} container_name: planet_aiprovider ports: - "8010:8010" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index af41451f..e8ce9ccc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,20 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.45.0] — 2026-04-29 + +### ✨ Highlights +- 采集任务新增阶段级量化进度,`fetching` 可展示百分比、阶段说明和字节下载量。 +- AI Provider 启动链路支持从 `aiprovider/.env` 与 `~/.zshrc` 注入运行期配置,并避免密钥/模型变化触发镜像重建。 +- AI Provider Docker build context 收敛到服务必需文件,`uv sync` 接入 BuildKit 缓存以减少重复下载。 + +### 🔧 Improvements +- IPtoASN、OpenGeoFeed、NRO delegated 下载型采集器接入真实字节进度上报。 +- 数据源页、采集中任务弹窗和任务历史页展示阶段摘要,并在 tooltip 中保留完整进度细节。 +- 调整 Earth 船只默认高度偏移,进一步贴近地表展示。 + +--- + ## [0.44.2] — 2026-04-29 ### 📝 Documentation diff --git a/docs/technical/en/manual.md b/docs/technical/en/manual.md index 2bae081a..e6cc0327 100644 --- a/docs/technical/en/manual.md +++ b/docs/technical/en/manual.md @@ -55,6 +55,30 @@ Parameters: | `--allow-lan` | Enable LAN access | | `--verbose` | Show more command output during execution | +### AI Provider Environment and Builds + +AI Provider runtime configuration can live in `aiprovider/.env` or in matching variables in `~/.zshrc`. `planet.sh` reads simple `export AI_...=...` / `AI_...=...` lines and passes them to the container at startup. + +Changing model, API key, or base URL does not rebuild the image. Restart only AI Provider to pick up runtime configuration changes: + +```bash +./planet.sh restart -a +``` + +For complex shell expansion in `~/.zshrc`, opt in explicitly: + +```bash +PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a +``` + +To ignore `~/.zshrc` during troubleshooting: + +```bash +PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a +``` + +The AI Provider Docker build context is intentionally limited to the files required by the service, and `uv sync` uses a BuildKit cache mount so dependency downloads are reused after the first build. + ### Stop ```bash diff --git a/docs/technical/en/quickstart.md b/docs/technical/en/quickstart.md index baa2ee48..2f0a5a33 100644 --- a/docs/technical/en/quickstart.md +++ b/docs/technical/en/quickstart.md @@ -24,6 +24,12 @@ This script checks and syncs common dependencies, and generates if missing: - `aiprovider/.env` - `frontend/.env.local` +Personal AI Provider configuration can also live in `~/.zshrc`. `planet.sh` reads simple `export AI_...=...` / `AI_...=...` lines and passes them to the AI Provider container. After changing model, key, or base URL, restart only AI Provider: + +```bash +./planet.sh restart -a +``` + ## 1. Start Services From the repository root: diff --git a/docs/technical/zh/earth-layer-style-reference.md b/docs/technical/zh/earth-layer-style-reference.md index 369ef053..2225a1e4 100644 --- a/docs/technical/zh/earth-layer-style-reference.md +++ b/docs/technical/zh/earth-layer-style-reference.md @@ -190,8 +190,8 @@ | 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 | | --- | --- | --- | --- | -| 船只半径偏移 | `VESSEL_CONFIG.altitudeOffset` | `0.56` | 普通 marker 位置 | -| 船只轨迹半径偏移 | `VESSEL_CONFIG.track.altitudeOffset` | `0.7` | 选中船只轨迹线 | +| 船只半径偏移 | `VESSEL_CONFIG.altitudeOffset` | `0.2` | 普通 marker 位置,贴近真实地形基础层 | +| 船只轨迹半径偏移 | `VESSEL_CONFIG.track.altitudeOffset` | `0.22` | 选中船只轨迹线,略高于船只 marker 以保持可见 | | 船只 renderOrder | local `VESSEL_RENDER_ORDER` | `4.4` | 普通 marker 和交互 overlay | | 船只轨迹 renderOrder | `VESSEL_RENDER_ORDER - 0.1` | `4.3` | 低于船只 marker | | 船只点像素尺寸 | local `VESSEL_POINT_SIZE` | `34` | 普通 marker 与 hover / locked overlay 共享尺寸 | diff --git a/docs/technical/zh/manual.md b/docs/technical/zh/manual.md index 5104254d..ceca903c 100644 --- a/docs/technical/zh/manual.md +++ b/docs/technical/zh/manual.md @@ -55,6 +55,52 @@ | `--allow-lan` | 允许局域网访问 | | `--verbose` | 在执行过程中显示更多命令输出 | +### AI Provider 环境变量和构建 + +AI Provider 的运行期配置可以放在两处: + +| 位置 | 适合内容 | 说明 | +| --- | --- | --- | +| `aiprovider/.env` | 团队约定的本地默认配置 | Docker Compose 会作为 `env_file` 读取 | +| `~/.zshrc` | 个人机器上的 provider、模型、密钥和代理变量 | `planet.sh` 启动时会读取常见的 `AI_*`、`SERVICE_*`、`PYTHON_IMAGE`、`UV_IMAGE`、代理变量 | + +推荐写法: + +```bash +export AI_PROVIDER=minimax +export AI_PROVIDER_API=anthropic-messages +export AI_BASE_URL=https://api.example.com/anthropic +export AI_API_KEY=sk-change-me +export AI_MODEL=MiniMax-M2.7 +export AI_PROVIDER_SERVICE_TOKEN=change_me +``` + +默认情况下,`planet.sh` 只静态解析 `~/.zshrc` 中简单的 `export KEY=value` 或 `KEY=value` 行,避免 shell 主题、插件或交互初始化拖慢启动。如果变量依赖复杂 shell 展开,可以显式启用 source 模式: + +```bash +PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a +``` + +如需完全忽略 `~/.zshrc`: + +```bash +PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a +``` + +AI Provider 镜像只在代码、Dockerfile、Compose 配置或相关 Python 依赖变化时重建。修改 `aiprovider/.env` 或 `~/.zshrc` 中的模型、密钥、Base URL 不会触发镜像重建;重启 AI Provider 即可让容器读取新配置: + +```bash +./planet.sh restart -a +``` + +构建较慢时,优先判断当前卡在哪一层: + +| 现象 | 常见原因 | 处理方式 | +| --- | --- | --- | +| `transferring context` 很大 | Docker build context 包含前端资源、PDF、数据目录等无关文件 | 当前仓库通过 `.dockerignore` 只发送 AI Provider 必需文件 | +| `uv sync` 下载依赖较慢 | 首次构建或缓存为空,网络访问 Python 包较慢 | 等待首次构建完成;后续会复用 BuildKit 的 uv 下载缓存 | +| 改密钥后仍显示旧配置 | 容器尚未重启 | 执行 `./planet.sh restart -a` | + ### 停止 ```bash diff --git a/docs/technical/zh/ops-planet-sh-startup.md b/docs/technical/zh/ops-planet-sh-startup.md index 3da819bf..e4cc1643 100644 --- a/docs/technical/zh/ops-planet-sh-startup.md +++ b/docs/technical/zh/ops-planet-sh-startup.md @@ -46,6 +46,8 @@ compute_ai_provider_build_fingerprint() { find aiprovider \ -type f \ ! -path '*/__pycache__/*' \ + ! -name '.env' \ + ! -name '.env.*' \ ! -name '*.pyc' \ ! -name '*.pyo' \ | LC_ALL=C sort \ @@ -57,6 +59,58 @@ compute_ai_provider_build_fingerprint() { 速度提升约 10 倍(大量小文件场景),误报率相同(mtime+size 变化 ≡ 文件被修改)。 +`.env` 和 `.env.*` 被排除在 fingerprint 外。它们属于运行期配置,不应该因为修改模型、密钥或 Base URL 触发镜像重建。 + +### Docker build context 收敛 + +AI Provider 镜像只需要根目录的 `pyproject.toml`、`uv.lock` 和 `aiprovider/` 代码。仓库中还包含前端静态大图、PDF、历史数据和 Unreal 资料,如果 build context 使用整个仓库,`transferring context` 会浪费大量时间。 + +当前通过根目录 `.dockerignore` 收敛上下文: + +```dockerignore +** + +!pyproject.toml +!uv.lock +!aiprovider/ +!aiprovider/** + +aiprovider/.env +aiprovider/.env.* +!aiprovider/.env.example +``` + +Dockerfile 也从全仓复制改为只复制 AI Provider 代码: + +```dockerfile +COPY pyproject.toml uv.lock /app/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev + +COPY aiprovider /app/aiprovider +``` + +`uv sync` 使用 BuildKit cache mount 后,首次构建仍可能受网络影响;后续构建会复用 `/root/.cache/uv`,依赖下载不再重复从零开始。 + +### 运行期配置来源 + +`planet.sh` 启动 AI Provider 前会生成临时 env-file,并把它传给 Compose 或手动 `docker run` fallback。配置优先来自: + +1. `aiprovider/.env` +2. `~/.zshrc` 中简单的 `export AI_...=...` 或 `AI_...=...` 行 + +默认解析是静态的,只覆盖 AI Provider、镜像、代理相关变量,避免执行交互 shell 初始化。如果确实需要复杂 shell 展开,可以显式启用: + +```bash +PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a +``` + +如果排查时需要忽略个人 shell 配置: + +```bash +PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a +``` + ### 跳过重建的原理 fingerprint 一致时不执行 `docker compose build`,而是: @@ -151,4 +205,7 @@ PY ## 相关文件 - `planet.sh` — 全量修改 +- `.dockerignore` — 收敛 AI Provider Docker build context +- `aiprovider/Dockerfile` — 只复制 AI Provider 代码,并为 `uv sync` 启用 BuildKit cache mount +- `docker-compose.yml` / `docker-compose.simple.yml` — 读取 `planet.sh` 生成的运行期 env-file - `scripts/compute_aiprovider_dependency_fingerprint.py` — 依赖 fingerprint(未改动) diff --git a/docs/technical/zh/quickstart.md b/docs/technical/zh/quickstart.md index 131e3c43..63671435 100644 --- a/docs/technical/zh/quickstart.md +++ b/docs/technical/zh/quickstart.md @@ -24,6 +24,12 @@ - `aiprovider/.env` - `frontend/.env.local` +AI Provider 的个人配置也可以放在 `~/.zshrc`。`planet.sh` 会读取简单的 `export AI_...=...` 或 `AI_...=...` 行,并在启动 AI Provider 时传给容器。修改模型、密钥或 Base URL 后,通常只需要重启 AI Provider: + +```bash +./planet.sh restart -a +``` + ## 1. 启动服务 在仓库根目录执行: diff --git a/docs/version-history.md b/docs/version-history.md index b1606a90..613c4bd8 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.44.2` +- `dev` 当前开发分支历史推导到:`0.45.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.45.0` | feature | `dev` | `pending` | 新增采集任务 fetching 阶段量化进度,收敛 AI Provider 运行期环境注入和 Docker build context | | `0.44.2` | bugfix | `dev` | `pending` | 补充 Earth 船只批量渲染、屏幕拾取、图层顺序、样式参考和性能计划状态文档 | | `0.44.1` | bugfix | `dev` | `pending` | 优化 Earth 船只批量渲染性能,修复拖动卡顿、拾取错位、交互态方向/尺寸和地表压盖问题 | | `0.44.0` | feature | `dev` | `pending` | 重构数据源目录与采集器设置,新增 BarentsWatch AIS 连接教程、Earth 船只/缩放体验优化和仪表盘前端重启 | diff --git a/frontend/package.json b/frontend/package.json index f859dd92..a79f7de2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.44.2", + "version": "0.45.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index 8b420d2c..b8d6b46b 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -209,7 +209,7 @@ export const PATHS = { }; export const VESSEL_CONFIG = { - altitudeOffset: 0.56, + altitudeOffset: 0.2, maxRenderedMarkers: 5000, marker: { baseScale: 7.5, @@ -232,7 +232,7 @@ export const VESSEL_CONFIG = { max: 2.4, }, track: { - altitudeOffset: 0.7, + altitudeOffset: 0.22, color: 0x7dd3fc, opacity: 0.82, }, diff --git a/frontend/src/pages/DataSources/DataSources.tsx b/frontend/src/pages/DataSources/DataSources.tsx index 496966a7..620f1a9a 100644 --- a/frontend/src/pages/DataSources/DataSources.tsx +++ b/frontend/src/pages/DataSources/DataSources.tsx @@ -30,6 +30,7 @@ import axios, { type AxiosResponse } from 'axios' import AppLayout from '../../components/AppLayout/AppLayout' import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay' import { formatDateTimeZhCN } from '../../utils/datetime' +import { formatPhaseMetric, getPhaseDisplay, getPhaseSummary } from '../../utils/phaseProgress' const { Text } = Typography const COLLECTION_REFRESH_DELAY_MS = 800 @@ -54,6 +55,11 @@ interface BuiltInDataSource { task_id: number | null progress: number | null phase?: string | null + phase_progress?: number | null + phase_message?: string | null + phase_current?: number | null + phase_total?: number | null + phase_unit?: string | null records_processed: number | null total_records: number | null is_free?: boolean @@ -107,6 +113,11 @@ interface UnifiedDataSource { is_running?: boolean progress?: number | null phase?: string | null + phase_progress?: number | null + phase_message?: string | null + phase_current?: number | null + phase_total?: number | null + phase_unit?: string | null task_id?: number | null created_at?: string updated_at?: string | null @@ -129,6 +140,11 @@ type TriggerDatasourceConflict = { message?: string progress?: number | null phase?: string | null + phase_progress?: number | null + phase_message?: string | null + phase_current?: number | null + phase_total?: number | null + phase_unit?: string | null records_processed?: number | null total_records?: number | null } @@ -142,6 +158,11 @@ type DatasourceTaskStatus = { task_id?: number | null progress?: number | null phase?: string | null + phase_progress?: number | null + phase_message?: string | null + phase_current?: number | null + phase_total?: number | null + phase_unit?: string | null records_processed?: number | null total_records?: number | null status?: string | null @@ -166,6 +187,11 @@ function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource { is_running: source.is_running, progress: source.progress, phase: source.phase, + phase_progress: source.phase_progress, + phase_message: source.phase_message, + phase_current: source.phase_current, + phase_total: source.phase_total, + phase_unit: source.phase_unit, task_id: source.task_id, is_free: source.is_free, requires_credentials: source.requires_credentials, @@ -291,7 +317,7 @@ function DataSources() { content: (
{taskInfo?.message || '当前采集任务仍在运行,重新触发会丢失本次未完成进度。'}
-当前阶段: {taskInfo?.phase || 'running'}
+当前阶段: {getPhaseDisplay(taskInfo || {})}
当前进度: {typeof taskInfo?.progress === 'number' ? `${Math.round(taskInfo.progress)}%` : '未知'}
确认后会强制取消当前采集,并重新开始采集。