From ba54545ac786fbbb72afe6612c72e1a9e3810b91 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Wed, 29 Apr 2026 23:43:54 +0800 Subject: [PATCH] release: bump version to 0.45.0 --- .dockerignore | 13 +++ VERSION | 2 +- aiprovider/Dockerfile | 7 +- backend/app/api/v1/datasources.py | 28 ++++++- backend/app/api/v1/tasks.py | 24 +++++- backend/app/db/session.py | 7 +- backend/app/models/task.py | 7 +- backend/app/services/collectors/base.py | 60 +++++++++++++- backend/app/services/collectors/iptoasn.py | 20 ++++- .../app/services/collectors/nro_delegated.py | 11 +++ .../app/services/collectors/opengeofeed.py | 11 +++ backend/tests/test_collectors.py | 26 ++++++ backend/tests/test_models.py | 19 +++++ database_schema.sql | 6 ++ docker-compose.simple.yml | 3 + docker-compose.yml | 1 + docs/CHANGELOG.md | 14 ++++ docs/technical/en/manual.md | 24 ++++++ docs/technical/en/quickstart.md | 6 ++ .../zh/earth-layer-style-reference.md | 4 +- docs/technical/zh/manual.md | 46 +++++++++++ docs/technical/zh/ops-planet-sh-startup.md | 57 +++++++++++++ docs/technical/zh/quickstart.md | 6 ++ docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/js/constants.js | 4 +- .../src/pages/DataSources/DataSources.tsx | 46 +++++++++-- frontend/src/pages/Tasks/Tasks.tsx | 29 ++++++- frontend/src/utils/phaseProgress.ts | 61 ++++++++++++++ planet.sh | 80 +++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 32 files changed, 602 insertions(+), 29 deletions(-) create mode 100644 .dockerignore create mode 100644 frontend/src/utils/phaseProgress.ts 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)}%` : '未知'}

确认后会强制取消当前采集,并重新开始采集。

@@ -474,7 +500,11 @@ function DataSources() { return {record.is_active ? '启用' : '禁用'} } if (record.is_running) { - return {record.phase || '运行中'}{record.progress ? ` ${Math.round(record.progress)}%` : ''} + return ( + + {getPhaseSummary(record)} + + ) } if (!record.last_status) return 未执行 return {record.last_status} @@ -603,8 +633,13 @@ function DataSources() { {source.source}{source.task_id ? ` · #${source.task_id}` : ''} - {source.phase || 'running'} + + {getPhaseSummary(source)} + + {source.phase_message ? ( + {source.phase_message} + ) : null} - 已处理 {source.records_processed ?? 0} - {source.total_records ? ` / ${source.total_records}` : ''} + {source.phase_unit === 'bytes' + ? `下载 ${formatPhaseMetric(source) || '准备中'}` + : `已处理 ${source.records_processed ?? 0}${source.total_records ? ` / ${source.total_records}` : ''}`} diff --git a/frontend/src/pages/Tasks/Tasks.tsx b/frontend/src/pages/Tasks/Tasks.tsx index f7af6279..341b42ba 100644 --- a/frontend/src/pages/Tasks/Tasks.tsx +++ b/frontend/src/pages/Tasks/Tasks.tsx @@ -1,16 +1,26 @@ import { useEffect, useState } from 'react' -import { Table, Tag, Card, Row, Col, Statistic, Button } from 'antd' +import { Table, Tag, Card, Row, Col, Statistic, Button, Tooltip } from 'antd' import { ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons' import { useAuthStore } from '../../stores/auth' import AppLayout from '../../components/AppLayout/AppLayout' import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion' import { formatDateTimeZhCN } from '../../utils/datetime' +import { getPhaseDisplay, getPhaseSummary } from '../../utils/phaseProgress' interface Task { id: number - collector: string + collector?: string + datasource_name?: string status: 'success' | 'failed' | 'running' | 'pending' + 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 + total_records?: number | null + progress?: number | null started_at: string completed_at: string duration_seconds: number @@ -53,7 +63,20 @@ function Tasks() { title: '收集器', dataIndex: 'collector', key: 'collector', - render: (c: string) => {c}, + render: (_: string, task: Task) => {task.collector || task.datasource_name || '-'}, + }, + { + title: '阶段', + dataIndex: 'phase', + key: 'phase', + render: (_: string, task: Task) => { + const detail = task.phase ? getPhaseDisplay(task) : null + return detail ? ( + + {getPhaseSummary(task)} + + ) : '-' + }, }, { title: '状态', diff --git a/frontend/src/utils/phaseProgress.ts b/frontend/src/utils/phaseProgress.ts new file mode 100644 index 00000000..f1c282a1 --- /dev/null +++ b/frontend/src/utils/phaseProgress.ts @@ -0,0 +1,61 @@ +export type PhaseProgressLike = { + phase?: string | null + phase_progress?: number | null + phase_message?: string | null + phase_current?: number | null + phase_total?: number | null + phase_unit?: string | null +} + +const BYTE_UNIT_BASE = 1024 +const BYTE_UNITS = ['B', 'KB', 'MB', 'GB'] as const + +export function formatBytes(value: number): string { + if (!Number.isFinite(value) || value <= 0) return '0 B' + let size = value + let unitIndex = 0 + while (size >= BYTE_UNIT_BASE && unitIndex < BYTE_UNITS.length - 1) { + size /= BYTE_UNIT_BASE + unitIndex += 1 + } + const digits = unitIndex === 0 ? 0 : size >= 10 ? 1 : 2 + return `${size.toFixed(digits)} ${BYTE_UNITS[unitIndex]}` +} + +export function formatPhaseMetric(source: Pick): string | null { + const current = source.phase_current + const total = source.phase_total + if (typeof current !== 'number') return null + if (source.phase_unit === 'bytes') { + return typeof total === 'number' && total > 0 + ? `${formatBytes(current)} / ${formatBytes(total)}` + : formatBytes(current) + } + if (typeof total === 'number' && total > 0) { + return `${current} / ${total}${source.phase_unit ? ` ${source.phase_unit}` : ''}` + } + return `${current}${source.phase_unit ? ` ${source.phase_unit}` : ''}` +} + +export function getPhaseDisplay(source: PhaseProgressLike, fallback = 'running'): string { + const phase = source.phase || fallback + const parts = [phase] + if (typeof source.phase_progress === 'number') { + parts.push(`${Math.round(source.phase_progress)}%`) + } + if (source.phase_message) { + parts.push(source.phase_message) + } + const metric = formatPhaseMetric(source) + if (metric) { + parts.push(metric) + } + return parts.join(' · ') +} + +export function getPhaseSummary(source: PhaseProgressLike, fallback = 'running'): string { + const phase = source.phase || fallback + return typeof source.phase_progress === 'number' + ? `${phase} ${Math.round(source.phase_progress)}%` + : phase +} diff --git a/planet.sh b/planet.sh index 2d0e24ba..7a492d20 100755 --- a/planet.sh +++ b/planet.sh @@ -6,6 +6,48 @@ SCRIPT_PATH="${(%):-%N}" SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" cd "$SCRIPT_DIR" +load_user_zshrc_exported_env() { + local load_mode="${PLANET_LOAD_ZSHRC_ENV:-1}" + [ "$load_mode" != "0" ] || return 0 + [ "$load_mode" != "false" ] || return 0 + [ -f "$HOME/.zshrc" ] || return 0 + + local env_line env_name env_value + if [ "$load_mode" = "source" ]; then + command -v zsh >/dev/null 2>&1 || return 0 + while IFS= read -r env_line; do + env_name="${env_line%%=*}" + env_value="${env_line#*=}" + case "$env_name" in + AI_*|SERVICE_NAME|SERVICE_VERSION|PYTHON_IMAGE|UV_IMAGE|DOCKER_BUILDKIT|COMPOSE_DOCKER_CLI_BUILD|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|http_proxy|https_proxy|no_proxy) + export "${env_name}=${env_value}" + ;; + esac + done < <(zsh -fc 'source ~/.zshrc >/dev/null 2>&1; env' 2>/dev/null || true) + return 0 + fi + + while IFS= read -r env_line; do + env_line="$(printf "%s" "$env_line" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')" + case "$env_line" in + ""|\#*) continue ;; + export\ *) env_line="${env_line#export }" ;; + esac + env_name="${env_line%%=*}" + env_value="${env_line#*=}" + [ "$env_name" != "$env_value" ] || continue + env_name="$(printf "%s" "$env_name" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')" + env_value="$(printf "%s" "$env_value" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//;s/^"//;s/"$//;s/^'\''//;s/'\''$//')" + case "$env_name" in + AI_*|SERVICE_NAME|SERVICE_VERSION|PYTHON_IMAGE|UV_IMAGE|DOCKER_BUILDKIT|COMPOSE_DOCKER_CLI_BUILD|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|http_proxy|https_proxy|no_proxy) + export "${env_name}=${env_value}" + ;; + esac + done < "$HOME/.zshrc" +} + +load_user_zshrc_exported_env + RED='\033[38;5;203m' GREEN='\033[38;5;114m' YELLOW='\033[38;5;221m' @@ -68,7 +110,23 @@ AI_PROVIDER_BUILD_STAMP_FILE="$HOME/.cache/planet/aiprovider_build.sha256" AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log" AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}" AI_PROVIDER_CONTAINER_NAME="${AI_PROVIDER_CONTAINER_NAME:-planet_aiprovider}" +PLANET_AI_PROVIDER_RUNTIME_ENV_FILE="${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-/tmp/planet_aiprovider_runtime.env}" AI_PROVIDER_RECREATE_REQUIRED=0 +AI_PROVIDER_RUNTIME_ENV_NAMES=( + SERVICE_NAME + SERVICE_VERSION + AI_PROVIDER + AI_PROVIDER_API + AI_BASE_URL + AI_API_KEY + AI_MODEL + AI_TIMEOUT_SECONDS + AI_HTTP_RETRY_ATTEMPTS + AI_MAX_TOKENS + AI_ANTHROPIC_VERSION + AI_ANALYSIS_SYSTEM_PROMPT + AI_PROVIDER_SERVICE_TOKEN +) prepend_path_once() { local path_entry="$1" @@ -590,6 +648,8 @@ compute_ai_provider_build_fingerprint() { find aiprovider \ -type f \ ! -path '*/__pycache__/*' \ + ! -name '.env' \ + ! -name '.env.*' \ ! -name '*.pyc' \ ! -name '*.pyo' \ | LC_ALL=C sort \ @@ -633,6 +693,9 @@ run_ai_provider_container_manually() { if [ -f "$SCRIPT_DIR/aiprovider/.env" ]; then env_file_args=(--env-file "$SCRIPT_DIR/aiprovider/.env") fi + if [ -s "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" ]; then + env_file_args+=(--env-file "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE") + fi docker run -d \ --name "$AI_PROVIDER_CONTAINER_NAME" \ @@ -641,6 +704,22 @@ run_ai_provider_container_manually() { "$AI_PROVIDER_IMAGE_NAME" >/dev/null } +write_ai_provider_runtime_env_file() { + local env_name env_value + + : > "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" + chmod 600 "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" 2>/dev/null || true + + for env_name in "${AI_PROVIDER_RUNTIME_ENV_NAMES[@]}"; do + if [ -n "${(P)env_name+x}" ]; then + env_value="${(P)env_name}" + printf "%s=%s\n" "$env_name" "$env_value" >> "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" + fi + done + + export PLANET_AI_PROVIDER_RUNTIME_ENV_FILE +} + recreate_ai_provider_container() { local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" @@ -1156,6 +1235,7 @@ start_ai_provider_service() { local retry=1 set_wait_detail "启动 AI Provider" + write_ai_provider_runtime_env_file ensure_ai_provider_image_current while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do diff --git a/pyproject.toml b/pyproject.toml index 591a9c46..75519d98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.44.2" +version = "0.45.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 50fb13fb..d0a7c45c 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.44.2" +version = "0.45.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" },