From 9a50e72bd14f0530a7bbf31b500a3d572b326e9c Mon Sep 17 00:00:00 2001 From: linkong Date: Tue, 7 Apr 2026 17:30:27 +0800 Subject: [PATCH] feat: add aiprovider service foundation --- README.md | 83 +++++ VERSION | 2 +- aiprovider/.env.example | 34 ++ aiprovider/Dockerfile | 23 ++ aiprovider/README.md | 81 +++++ aiprovider/__init__.py | 1 + aiprovider/config.py | 35 +++ aiprovider/main.py | 79 +++++ aiprovider/provider_service.py | 240 +++++++++++++++ aiprovider/schemas.py | 27 ++ backend/.env.example | 31 +- backend/Dockerfile | 15 +- backend/app/api/main.py | 2 + backend/app/api/v1/ai.py | 39 +++ backend/app/core/config.py | 6 + backend/app/schemas/ai.py | 27 ++ backend/app/services/ai_client.py | 109 +++++++ backend/app/services/system_control.py | 4 + backend/requirements.txt | 19 -- backend/scripts/system_restart_runner.py | 2 + backend/tests/test_api.py | 87 ++++++ docker-compose.local-model.yml | 42 +++ docker-compose.simple.yml | 8 + docker-compose.yml | 13 + docs/CHANGELOG.md | 31 ++ docs/aiprovider.md | 290 ++++++++++++++++++ docs/version-history.md | 3 +- frontend/.env.example | 3 +- frontend/package.json | 2 +- frontend/src/pages/BGP/BGP.tsx | 132 ++------ frontend/src/pages/Dashboard/Dashboard.tsx | 20 +- .../situational-awareness/http-gateway.ts | 46 +++ .../services/situational-awareness/index.ts | 23 ++ .../situational-awareness/mock-gateway.ts | 35 +++ .../services/situational-awareness/port.ts | 5 + .../services/situational-awareness/types.ts | 112 +++++++ planet.sh | 82 ++++- project_context.md | 9 +- pyproject.toml | 2 +- rules.md | 2 +- scripts/bootstrap-dev.sh | 124 ++++++++ uv.lock | 2 +- 42 files changed, 1766 insertions(+), 166 deletions(-) create mode 100644 aiprovider/.env.example create mode 100644 aiprovider/Dockerfile create mode 100644 aiprovider/README.md create mode 100644 aiprovider/__init__.py create mode 100644 aiprovider/config.py create mode 100644 aiprovider/main.py create mode 100644 aiprovider/provider_service.py create mode 100644 aiprovider/schemas.py create mode 100644 backend/app/api/v1/ai.py create mode 100644 backend/app/schemas/ai.py create mode 100644 backend/app/services/ai_client.py delete mode 100644 backend/requirements.txt create mode 100644 docker-compose.local-model.yml create mode 100644 docs/aiprovider.md create mode 100644 frontend/src/services/situational-awareness/http-gateway.ts create mode 100644 frontend/src/services/situational-awareness/index.ts create mode 100644 frontend/src/services/situational-awareness/mock-gateway.ts create mode 100644 frontend/src/services/situational-awareness/port.ts create mode 100644 frontend/src/services/situational-awareness/types.ts create mode 100755 scripts/bootstrap-dev.sh diff --git a/README.md b/README.md index 972c3978..25665855 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,11 @@ ## 快速启动 ```bash +# 新机器首次初始化 +./scripts/bootstrap-dev.sh +# 会自动安装/检查 uv、bun,并同步 Python/前端依赖 +# 会在缺少时生成 backend/.env、aiprovider/.env、frontend/.env.local + # 启动前后端服务 ./planet.sh start @@ -204,6 +209,84 @@ 启动服务后访问: `http://localhost:8000/docs` +## AI 接口预留 + +项目现在采用“两层”设计: + +- 主后端暴露稳定业务接口: `GET /api/v1/ai/provider/status`、`POST /api/v1/ai/situational-awareness/analyze` +- 独立 `aiprovider` 服务负责适配具体模型供应商 + +这样前端和业务代码不直接依赖 OpenAI、本地模型网关或其他订阅服务,后续切换部署方式只需要调整环境变量。 + +主后端建议配置: + +```env +AI_PROVIDER_SERVICE_URL=http://localhost:8010 +AI_PROVIDER_SERVICE_TOKEN=change_me +AI_PROVIDER_TIMEOUT_SECONDS=60 +``` + +`aiprovider` 服务建议配置: + +```env +AI_PROVIDER=openai_compatible +AI_BASE_URL=https://api.openai.com/v1 +AI_API_KEY=your_api_key +AI_MODEL=gpt-4o-mini +AI_TIMEOUT_SECONDS=60 +AI_PROVIDER_SERVICE_TOKEN=change_me +``` + +OpenAI 兼容场景推荐使用: + +- `AI_PROVIDER=openai_compatible` + +Claude 兼容场景推荐使用: + +- `AI_PROVIDER=anthropic` +- `AI_PROVIDER=anthropic_compatible` +- `AI_PROVIDER=claude_compatible` + +Ollama 原生场景推荐使用: + +- `AI_PROVIDER=ollama` + +比如 MiniMax 或其他 Claude 兼容网关,可以这样配置: + +```env +AI_PROVIDER=claude_compatible +AI_BASE_URL=https://your-claude-compatible-endpoint.example.com +AI_API_KEY=your_api_key +AI_MODEL=your-claude-compatible-model +AI_TIMEOUT_SECONDS=60 +AI_MAX_TOKENS=1200 +AI_ANTHROPIC_VERSION=2023-06-01 +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` +- 其他服务优先调用主后端 `POST /api/v1/ai/situational-awareness/analyze` +- `backend -> aiprovider` 会透传 `X-Request-ID` +- `backend -> aiprovider` 与 `aiprovider -> 模型供应商` 都带轻量重试 + +详细文档: + +- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md) +- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md) + ## License 待定 diff --git a/VERSION b/VERSION index 1602c362..ca222b7c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.22.14 +0.23.0 diff --git a/aiprovider/.env.example b/aiprovider/.env.example new file mode 100644 index 00000000..cd5f57e1 --- /dev/null +++ b/aiprovider/.env.example @@ -0,0 +1,34 @@ +# Shared service settings +SERVICE_NAME=planet-ai-provider +SERVICE_VERSION=0.1.0 +AI_PROVIDER_SERVICE_TOKEN=change_me +AI_TIMEOUT_SECONDS=60 +AI_HTTP_RETRY_ATTEMPTS=2 +AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。 + +# Select one provider mode: +# - openai_compatible +# - claude_compatible +# - ollama +AI_PROVIDER=ollama + +# Common model selection +AI_MODEL=qwen2.5:7b + +# OpenAI-compatible example (vLLM / LM Studio / One API / local gateway) +# AI_PROVIDER=openai_compatible +# AI_BASE_URL=http://127.0.0.1:8001/v1 +# AI_API_KEY=local-key + +# Claude-compatible example (Anthropic / MiniMax / Claude-compatible gateway) +# AI_PROVIDER=claude_compatible +# AI_BASE_URL=http://127.0.0.1:8002 +# AI_API_KEY=local-key +# AI_MAX_TOKENS=1200 +# AI_ANTHROPIC_VERSION=2023-06-01 + +# Ollama native example +AI_BASE_URL=http://127.0.0.1:11434 +AI_API_KEY= +AI_MAX_TOKENS=1200 +AI_ANTHROPIC_VERSION=2023-06-01 diff --git a/aiprovider/Dockerfile b/aiprovider/Dockerfile new file mode 100644 index 00000000..c598f6be --- /dev/null +++ b/aiprovider/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.14-slim + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy + +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 + +COPY . /app + +EXPOSE 8010 + +CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "aiprovider.main:app", "--host", "0.0.0.0", "--port", "8010", "--reload"] diff --git a/aiprovider/README.md b/aiprovider/README.md new file mode 100644 index 00000000..2aae82be --- /dev/null +++ b/aiprovider/README.md @@ -0,0 +1,81 @@ +# AI Provider Service + +`aiprovider` 是独立的模型适配服务,负责把项目内部的分析请求转发到具体的大模型供应商。 + +完整使用说明见: + +- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md) + +当前支持: + +- `AI_PROVIDER=openai` +- `AI_PROVIDER=openai_compatible` +- `AI_PROVIDER=anthropic` +- `AI_PROVIDER=anthropic_compatible` +- `AI_PROVIDER=claude_compatible` +- `AI_PROVIDER=ollama` + +典型配置: + +```env +AI_PROVIDER=openai_compatible +AI_BASE_URL=https://api.openai.com/v1 +AI_API_KEY=your_api_key +AI_MODEL=gpt-4o-mini +AI_TIMEOUT_SECONDS=60 +AI_PROVIDER_SERVICE_TOKEN=change_me +``` + +Claude 兼容供应商示例: + +```env +AI_PROVIDER=claude_compatible +AI_BASE_URL=https://your-claude-compatible-endpoint.example.com +AI_API_KEY=your_api_key +AI_MODEL=your-claude-compatible-model +AI_TIMEOUT_SECONDS=60 +AI_MAX_TOKENS=1200 +AI_ANTHROPIC_VERSION=2023-06-01 +AI_PROVIDER_SERVICE_TOKEN=change_me +``` + +适用场景: + +- Anthropic 官方 Claude API +- Claude 兼容网关 +- MiniMax 等提供 Claude/Anthropic 风格消息接口的服务 + +Ollama 原生示例: + +```env +AI_PROVIDER=ollama +AI_BASE_URL=http://127.0.0.1:11434 +AI_API_KEY= +AI_MODEL=qwen2.5:7b +AI_TIMEOUT_SECONDS=60 +AI_PROVIDER_SERVICE_TOKEN=change_me +``` + +本地模型接入建议: + +- `vLLM`、`LM Studio`、`One API`:优先使用 `openai_compatible` +- `MiniMax`、Claude 兼容网关:使用 `claude_compatible` +- `Ollama`:可直接使用 `ollama` + +启动模板: + +- `aiprovider/.env.example` +- `docker-compose.local-model.yml` + +跨服务调用补充: + +- 业务服务优先调用主后端 `/api/v1/ai/...` +- 直接调用 `aiprovider` 时使用 `X-Provider-Token` +- 支持 `X-Request-ID` 透传 +- 内置轻量重试,适合跨机器 HTTP RPC 场景 + +接口: + +- `GET /health` +- `GET /v1/provider/status` +- `POST /v1/analyze` diff --git a/aiprovider/__init__.py b/aiprovider/__init__.py new file mode 100644 index 00000000..9a939890 --- /dev/null +++ b/aiprovider/__init__.py @@ -0,0 +1 @@ +"""AI provider adapter service package.""" diff --git a/aiprovider/config.py b/aiprovider/config.py new file mode 100644 index 00000000..4d668b4f --- /dev/null +++ b/aiprovider/config.py @@ -0,0 +1,35 @@ +from functools import lru_cache +from pathlib import Path + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + SERVICE_NAME: str = "planet-ai-provider" + SERVICE_VERSION: str = "0.1.0" + + AI_PROVIDER: str = "disabled" + AI_BASE_URL: str = "https://api.openai.com/v1" + AI_API_KEY: str = "" + AI_MODEL: str = "" + AI_TIMEOUT_SECONDS: int = 60 + AI_HTTP_RETRY_ATTEMPTS: int = 2 + AI_MAX_TOKENS: int = 1200 + AI_ANTHROPIC_VERSION: str = "2023-06-01" + AI_ANALYSIS_SYSTEM_PROMPT: str = ( + "你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。" + ) + + AI_PROVIDER_SERVICE_TOKEN: str = "" + + class Config: + env_file = Path(__file__).parent / ".env" + case_sensitive = True + + +@lru_cache() +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/aiprovider/main.py b/aiprovider/main.py new file mode 100644 index 00000000..afcc71af --- /dev/null +++ b/aiprovider/main.py @@ -0,0 +1,79 @@ +from uuid import uuid4 + +from fastapi import Depends, FastAPI, Header, HTTPException, Request, Response, status + +from aiprovider.config import settings +from aiprovider.provider_service import ProviderService +from aiprovider.schemas import ( + AIProviderStatusResponse, + SituationalAnalysisRequest, + SituationalAnalysisResponse, +) + +app = FastAPI( + title=settings.SERVICE_NAME, + version=settings.SERVICE_VERSION, + description="AI provider adapter service for Planet", +) + + +@app.middleware("http") +async def request_id_middleware(request: Request, call_next): + request_id = request.headers.get("X-Request-ID") or str(uuid4()) + request.state.request_id = request_id + response = await call_next(request) + response.headers["X-Request-ID"] = request_id + return response + + +def verify_service_token(x_provider_token: str | None = Header(default=None)) -> None: + expected = settings.AI_PROVIDER_SERVICE_TOKEN + if not expected: + return + if x_provider_token != expected: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid provider service token", + ) + + +def get_provider_service() -> ProviderService: + return ProviderService() + + +@app.get("/health") +async def health_check(): + return { + "status": "healthy", + "service": settings.SERVICE_NAME, + "version": settings.SERVICE_VERSION, + } + + +@app.get( + "/v1/provider/status", + response_model=AIProviderStatusResponse, + dependencies=[Depends(verify_service_token)], +) +async def get_provider_status( + response: Response, + request: Request, + provider_service: ProviderService = Depends(get_provider_service), +): + response.headers["X-Request-ID"] = request.state.request_id + return provider_service.get_status() + + +@app.post( + "/v1/analyze", + response_model=SituationalAnalysisResponse, + dependencies=[Depends(verify_service_token)], +) +async def analyze( + payload: SituationalAnalysisRequest, + response: Response, + request: Request, + provider_service: ProviderService = Depends(get_provider_service), +): + response.headers["X-Request-ID"] = request.state.request_id + return await provider_service.analyze(payload) diff --git a/aiprovider/provider_service.py b/aiprovider/provider_service.py new file mode 100644 index 00000000..763e9ca1 --- /dev/null +++ b/aiprovider/provider_service.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +import httpx +from fastapi import HTTPException, status + +from aiprovider.config import settings +from aiprovider.schemas import ( + AIProviderStatusResponse, + SituationalAnalysisRequest, + SituationalAnalysisResponse, +) + + +def _normalize_provider(value: str) -> str: + return (value or "disabled").strip().lower() + + +class ProviderService: + def __init__(self) -> None: + self.provider = _normalize_provider(settings.AI_PROVIDER) + self.base_url = settings.AI_BASE_URL.rstrip("/") + self.api_key = settings.AI_API_KEY + self.default_model = settings.AI_MODEL + self.timeout = settings.AI_TIMEOUT_SECONDS + self.http_retry_attempts = max(settings.AI_HTTP_RETRY_ATTEMPTS, 1) + self.max_tokens = settings.AI_MAX_TOKENS + self.anthropic_version = settings.AI_ANTHROPIC_VERSION + self.system_prompt = settings.AI_ANALYSIS_SYSTEM_PROMPT + + def get_status(self) -> AIProviderStatusResponse: + enabled = self.provider != "disabled" + configured = enabled and bool(self.base_url and self.api_key and self.default_model) + return AIProviderStatusResponse( + provider=self.provider, + enabled=enabled, + configured=configured, + model=self.default_model or None, + base_url=self.base_url if enabled else None, + ) + + async def analyze(self, payload: SituationalAnalysisRequest) -> SituationalAnalysisResponse: + if self.provider == "disabled": + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="AI provider is disabled. Configure AI_PROVIDER in .env to enable analysis.", + ) + + model = payload.preferred_model or self.default_model + if not self.base_url or not self.api_key or not model: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="AI provider is not fully configured. Check AI_BASE_URL, AI_API_KEY, and AI_MODEL.", + ) + + prompt = self._build_prompt(payload) + + if self.provider in {"openai", "openai_compatible"}: + data = await self._request_openai_compatible(model, prompt) + content = self._extract_openai_content(data) + elif self.provider in {"anthropic", "anthropic_compatible", "claude_compatible"}: + data = await self._request_anthropic_compatible(model, prompt) + content = self._extract_anthropic_content(data) + elif self.provider == "ollama": + data = await self._request_ollama(model, prompt) + content = self._extract_ollama_content(data) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported AI provider: {self.provider}", + ) + + return SituationalAnalysisResponse( + provider=self.provider, + model=model, + content=content, + raw_response=data, + ) + + def _build_prompt(self, payload: SituationalAnalysisRequest) -> str: + sections = [ + f"任务标题:\n{payload.title}", + f"分析目标:\n{payload.objective}", + ] + if payload.observations: + sections.append("观测事实:\n" + "\n".join(f"- {item}" for item in payload.observations)) + if payload.constraints: + sections.append("约束条件:\n" + "\n".join(f"- {item}" for item in payload.constraints)) + if payload.context: + sections.append(f"附加上下文:\n{payload.context}") + sections.append( + "请输出: 1) 态势摘要 2) 关键风险 3) 研判依据 4) 建议动作 5) 还缺少的数据。" + ) + return "\n\n".join(sections) + + async def _request_openai_compatible(self, model: str, prompt: str) -> dict[str, Any]: + request_body = { + "model": model, + "messages": [ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": prompt}, + ], + "temperature": 0.2, + } + return await self._post( + path="/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + request_body=request_body, + ) + + async def _request_anthropic_compatible(self, model: str, prompt: str) -> dict[str, Any]: + request_body = { + "model": model, + "system": self.system_prompt, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": prompt, + } + ], + } + ], + "max_tokens": self.max_tokens, + "temperature": 0.2, + } + return await self._post( + path="/messages", + headers={ + "x-api-key": self.api_key, + "anthropic-version": self.anthropic_version, + "Content-Type": "application/json", + }, + request_body=request_body, + ) + + async def _request_ollama(self, model: str, prompt: str) -> dict[str, Any]: + request_body = { + "model": model, + "stream": False, + "system": self.system_prompt, + "prompt": prompt, + "options": { + "temperature": 0.2, + }, + } + return await self._post( + path="/api/generate", + headers={ + "Content-Type": "application/json", + }, + request_body=request_body, + ) + + async def _post( + self, + path: str, + headers: dict[str, str], + request_body: dict[str, Any], + ) -> dict[str, Any]: + last_error: Exception | None = None + for attempt in range(1, self.http_retry_attempts + 1): + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + f"{self.base_url}{path}", + headers=headers, + json=request_body, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + last_error = exc + if attempt < self.http_retry_attempts and exc.response.status_code >= 500: + await asyncio.sleep(0.3 * attempt) + continue + detail = exc.response.text or "AI provider returned an error" + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"AI provider request failed: {detail}", + ) from exc + except httpx.HTTPError as exc: + last_error = exc + if attempt < self.http_retry_attempts: + await asyncio.sleep(0.3 * attempt) + continue + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to reach AI provider: {exc}", + ) from exc + + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"AI provider request failed: {last_error}", + ) + + def _extract_openai_content(self, payload: dict[str, Any]) -> str: + choices = payload.get("choices") or [] + if not choices: + return "" + + message = choices[0].get("message") or {} + content = message.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + return "".join( + item.get("text", "") + for item in content + if isinstance(item, dict) + ) + return "" + + def _extract_anthropic_content(self, payload: dict[str, Any]) -> str: + content = payload.get("content") + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + + fragments: list[str] = [] + for item in content: + if not isinstance(item, dict): + continue + if item.get("type") == "text" and isinstance(item.get("text"), str): + fragments.append(item["text"]) + return "".join(fragments) + + def _extract_ollama_content(self, payload: dict[str, Any]) -> str: + response = payload.get("response") + if isinstance(response, str): + return response + return "" diff --git a/aiprovider/schemas.py b/aiprovider/schemas.py new file mode 100644 index 00000000..5e07fbf1 --- /dev/null +++ b/aiprovider/schemas.py @@ -0,0 +1,27 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +class SituationalAnalysisRequest(BaseModel): + title: str = Field(..., min_length=1, max_length=200) + objective: str = Field(..., min_length=1, max_length=1000) + context: dict[str, Any] = Field(default_factory=dict) + observations: list[str] = Field(default_factory=list) + constraints: list[str] = Field(default_factory=list) + preferred_model: str | None = Field(default=None, max_length=200) + + +class SituationalAnalysisResponse(BaseModel): + provider: str + model: str + content: str + raw_response: dict[str, Any] = Field(default_factory=dict) + + +class AIProviderStatusResponse(BaseModel): + provider: str + enabled: bool + configured: bool + model: str | None = None + base_url: str | None = None diff --git a/backend/.env.example b/backend/.env.example index 9cb0296a..e69c62d3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,23 +1,26 @@ -# Database +PROJECT_NAME=Intelligent Planet Plan +APP_VERSION=0.23.0 + +SECRET_KEY=change_me_to_a_random_secret +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=0 +REFRESH_TOKEN_EXPIRE_DAYS=0 + POSTGRES_SERVER=localhost POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=planet_db +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/planet_db -# Redis REDIS_SERVER=localhost REDIS_PORT=6379 +REDIS_DB=0 +REDIS_URL=redis://localhost:6379/0 -# Security -SECRET_KEY=your-secret-key-change-in-production -ALGORITHM=HS256 -ACCESS_TOKEN_EXPIRE_MINUTES=15 -REFRESH_TOKEN_EXPIRE_DAYS=7 +AI_PROVIDER_SERVICE_URL=http://localhost:8010 +AI_PROVIDER_SERVICE_TOKEN=change_me +AI_PROVIDER_TIMEOUT_SECONDS=60 +AI_PROVIDER_RETRY_ATTEMPTS=2 -# API -API_V1_STR=/api/v1 -PROJECT_NAME="Intelligent Planet Plan" -VERSION=1.0.0 - -# CORS -CORS_ORIGINS=["http://localhost:3000", "http://localhost:8000"] +SPACETRACK_USERNAME= +SPACETRACK_PASSWORD= diff --git a/backend/Dockerfile b/backend/Dockerfile index 75a98ce8..88bd01a6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,19 +1,24 @@ -FROM python:3.11-slim +FROM python:3.14-slim + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/* -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +COPY pyproject.toml uv.lock /app/ +RUN uv sync --frozen --no-dev -COPY . . +COPY backend /app/backend +COPY VERSION /app/VERSION EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/app/api/main.py b/backend/app/api/main.py index b7611057..ad8883e2 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,5 +1,6 @@ from fastapi import APIRouter from app.api.v1 import ( + ai, auth, users, datasource_config, @@ -18,6 +19,7 @@ from app.api.v1 import ( api_router = APIRouter() api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(ai.router, prefix="/ai", tags=["ai"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router( datasource_config.router, prefix="/datasources", tags=["datasource-config"] diff --git a/backend/app/api/v1/ai.py b/backend/app/api/v1/ai.py new file mode 100644 index 00000000..490d356b --- /dev/null +++ b/backend/app/api/v1/ai.py @@ -0,0 +1,39 @@ +from uuid import uuid4 + +from fastapi import APIRouter, Depends, Request, Response + +from app.core.security import get_current_user +from app.models.user import User +from app.schemas.ai import ( + AIProviderStatusResponse, + SituationalAnalysisRequest, + SituationalAnalysisResponse, +) +from app.services.ai_client import AIProviderClient, get_ai_provider_client + +router = APIRouter() + + +@router.get("/provider/status", response_model=AIProviderStatusResponse) +async def get_ai_provider_status( + request: Request, + response: Response, + current_user: User = Depends(get_current_user), + provider_client: AIProviderClient = Depends(get_ai_provider_client), +): + request_id = request.headers.get("X-Request-ID") or str(uuid4()) + response.headers["X-Request-ID"] = request_id + return await provider_client.get_status(request_id=request_id) + + +@router.post("/situational-awareness/analyze", response_model=SituationalAnalysisResponse) +async def analyze_situational_awareness( + payload: SituationalAnalysisRequest, + request: Request, + response: Response, + current_user: User = Depends(get_current_user), + provider_client: AIProviderClient = Depends(get_ai_provider_client), +): + request_id = request.headers.get("X-Request-ID") or str(uuid4()) + response.headers["X-Request-ID"] = request_id + return await provider_client.analyze(payload, request_id=request_id) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6d53f83a..24e3559b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -37,6 +37,11 @@ class Settings(BaseSettings): SPACETRACK_USERNAME: str = "" SPACETRACK_PASSWORD: str = "" + AI_PROVIDER_SERVICE_URL: str = "http://localhost:8010" + AI_PROVIDER_SERVICE_TOKEN: str = "" + AI_PROVIDER_TIMEOUT_SECONDS: int = 60 + AI_PROVIDER_RETRY_ATTEMPTS: int = 2 + @property def REDIS_URL(self) -> str: return os.getenv( @@ -46,6 +51,7 @@ class Settings(BaseSettings): class Config: env_file = Path(__file__).parent.parent.parent / ".env" case_sensitive = True + extra = "ignore" @lru_cache() diff --git a/backend/app/schemas/ai.py b/backend/app/schemas/ai.py new file mode 100644 index 00000000..5e07fbf1 --- /dev/null +++ b/backend/app/schemas/ai.py @@ -0,0 +1,27 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +class SituationalAnalysisRequest(BaseModel): + title: str = Field(..., min_length=1, max_length=200) + objective: str = Field(..., min_length=1, max_length=1000) + context: dict[str, Any] = Field(default_factory=dict) + observations: list[str] = Field(default_factory=list) + constraints: list[str] = Field(default_factory=list) + preferred_model: str | None = Field(default=None, max_length=200) + + +class SituationalAnalysisResponse(BaseModel): + provider: str + model: str + content: str + raw_response: dict[str, Any] = Field(default_factory=dict) + + +class AIProviderStatusResponse(BaseModel): + provider: str + enabled: bool + configured: bool + model: str | None = None + base_url: str | None = None diff --git a/backend/app/services/ai_client.py b/backend/app/services/ai_client.py new file mode 100644 index 00000000..916a02d1 --- /dev/null +++ b/backend/app/services/ai_client.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import asyncio + +import httpx +from fastapi import HTTPException, status + +from app.core.config import settings +from app.schemas.ai import ( + AIProviderStatusResponse, + SituationalAnalysisRequest, + SituationalAnalysisResponse, +) + + +class AIProviderClient: + def __init__(self) -> None: + self.service_url = settings.AI_PROVIDER_SERVICE_URL.rstrip("/") + self.service_token = settings.AI_PROVIDER_SERVICE_TOKEN + self.timeout = settings.AI_PROVIDER_TIMEOUT_SECONDS + self.retry_attempts = max(settings.AI_PROVIDER_RETRY_ATTEMPTS, 1) + + def _headers(self, request_id: str | None = None) -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if self.service_token: + headers["X-Provider-Token"] = self.service_token + if request_id: + headers["X-Request-ID"] = request_id + return headers + + async def get_status(self, request_id: str | None = None) -> AIProviderStatusResponse: + if not self.service_url: + return AIProviderStatusResponse( + provider="unconfigured", + enabled=False, + configured=False, + model=None, + base_url=None, + ) + + data = await self._request("GET", "/v1/provider/status", request_id=request_id) + return AIProviderStatusResponse.model_validate(data) + + async def analyze( + self, + payload: SituationalAnalysisRequest, + request_id: str | None = None, + ) -> SituationalAnalysisResponse: + if not self.service_url: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="AI provider service URL is not configured.", + ) + + data = await self._request( + "POST", + "/v1/analyze", + json=payload.model_dump(), + request_id=request_id, + ) + return SituationalAnalysisResponse.model_validate(data) + + async def _request( + self, + method: str, + path: str, + json: dict | None = None, + request_id: str | None = None, + ) -> dict: + last_error: Exception | None = None + for attempt in range(1, self.retry_attempts + 1): + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.request( + method, + f"{self.service_url}{path}", + headers=self._headers(request_id), + json=json, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + last_error = exc + if attempt < self.retry_attempts and exc.response.status_code >= 500: + await asyncio.sleep(0.3 * attempt) + continue + detail = exc.response.text or "AI provider service returned an error" + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"AI provider service request failed: {detail}", + ) from exc + except httpx.HTTPError as exc: + last_error = exc + if attempt < self.retry_attempts: + await asyncio.sleep(0.3 * attempt) + continue + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to reach AI provider service: {exc}", + ) from exc + + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"AI provider service request failed: {last_error}", + ) + + +def get_ai_provider_client() -> AIProviderClient: + return AIProviderClient() diff --git a/backend/app/services/system_control.py b/backend/app/services/system_control.py index 226b5ba9..7176177e 100644 --- a/backend/app/services/system_control.py +++ b/backend/app/services/system_control.py @@ -19,6 +19,10 @@ ALLOWED_ACTIONS: dict[str, dict[str, Any]] = { "command": ["./planet.sh", "restart", "-b"], "recovery_mode": "backend", }, + "restart-ai-provider": { + "command": ["./planet.sh", "restart", "-a"], + "recovery_mode": "ai-provider", + }, "restart-database": { "command": ["./planet.sh", "restart", "-d"], "recovery_mode": "database", diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index 00d9865f..00000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,19 +0,0 @@ -fastapi>=0.109.0 -uvicorn[standard]>=0.27.0 -sqlalchemy[asyncio]>=2.0.25 -asyncpg>=0.29.0 -redis>=5.0.1 -pydantic>=2.5.0 -pydantic-settings>=2.1.0 -python-jose[cryptography]>=3.3.0 -passlib[bcrypt]>=1.7.4 -python-multipart>=0.0.6 -httpx>=0.26.0 -beautifulsoup4>=4.12.0 -aiofiles>=23.2.1 -python-dotenv>=1.0.0 -email-validator -apscheduler>=3.10.4 -pytest>=7.4.0 -pytest-asyncio>=0.23.0 -networkx>=3.0 diff --git a/backend/scripts/system_restart_runner.py b/backend/scripts/system_restart_runner.py index c2bb5c8c..a30ed2d6 100644 --- a/backend/scripts/system_restart_runner.py +++ b/backend/scripts/system_restart_runner.py @@ -59,6 +59,8 @@ def wait_for_recovery(action: str) -> tuple[bool, str]: recovery_mode = get_action_recovery_mode(action) if recovery_mode == "backend": return wait_for_http("http://localhost:8000/health"), "backend health recovery" + if recovery_mode == "ai-provider": + return wait_for_http("http://localhost:8010/health"), "ai provider health recovery" if recovery_mode == "database": return True, "database container restart completion" if recovery_mode == "system": diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 9b894df3..8c0aabde 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -10,6 +10,7 @@ from app.core.config import settings from app.core.security import create_access_token from app.db.session import get_db from app.models.user import User +from app.schemas.ai import AIProviderStatusResponse, SituationalAnalysisResponse @pytest.fixture @@ -156,3 +157,89 @@ async def test_invalid_token(): headers={"Authorization": "Bearer invalid_token"}, ) assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_ai_provider_status_with_auth(auth_headers): + """Test AI provider status endpoint""" + class _FakeAIProviderClient: + async def get_status(self, request_id=None): + return AIProviderStatusResponse( + provider="openai_compatible", + enabled=True, + configured=True, + model="test-model", + base_url="http://aiprovider:8010", + ) + + def override_get_current_user(): + return User( + id=1, + username="testuser", + email="test@example.com", + password_hash="hashed", + role="admin", + is_active=True, + ) + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + __import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(), + } + transport = ASGITransport(app=app) + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/ai/provider/status", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "provider" in data + assert "configured" in data + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers): + """Test AI analysis endpoint proxies provider service response""" + class _FakeAIProviderClient: + async def analyze(self, _payload, request_id=None): + return SituationalAnalysisResponse( + provider="openai_compatible", + model="test-model", + content="1) 态势摘要: 测试返回", + raw_response={"id": "mock-response"}, + ) + + def override_get_current_user(): + return User( + id=1, + username="testuser", + email="test@example.com", + password_hash="hashed", + role="admin", + is_active=True, + ) + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + __import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(), + } + transport = ASGITransport(app=app) + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/ai/situational-awareness/analyze", + headers=auth_headers, + json={ + "title": "BGP 异常研判", + "objective": "给出当前异常的风险摘要和建议动作", + "observations": ["collector A 在 5 分钟内出现多个 origin 变更"], + "constraints": ["不要假设缺失数据"], + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["provider"] == "openai_compatible" + assert data["content"] + finally: + app.dependency_overrides.clear() diff --git a/docker-compose.local-model.yml b/docker-compose.local-model.yml new file mode 100644 index 00000000..f7720f0b --- /dev/null +++ b/docker-compose.local-model.yml @@ -0,0 +1,42 @@ +version: '3.8' + +services: + ollama: + image: ollama/ollama:latest + container_name: planet_ollama + ports: + - "11434:11434" + volumes: + - ollama_data:/root/.ollama + healthcheck: + test: ["CMD", "ollama", "list"] + interval: 20s + timeout: 10s + retries: 10 + + aiprovider: + build: + context: . + dockerfile: aiprovider/Dockerfile + container_name: planet_aiprovider + ports: + - "8010:8010" + environment: + AI_PROVIDER: ollama + AI_BASE_URL: http://ollama:11434 + AI_API_KEY: "" + AI_MODEL: qwen2.5:7b + AI_TIMEOUT_SECONDS: 60 + AI_MAX_TOKENS: 1200 + AI_PROVIDER_SERVICE_TOKEN: change_me + depends_on: + ollama: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8010/health"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + ollama_data: diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml index 0306d062..04d9272a 100644 --- a/docker-compose.simple.yml +++ b/docker-compose.simple.yml @@ -1,6 +1,14 @@ version: '3.8' services: + aiprovider: + build: + context: . + dockerfile: aiprovider/Dockerfile + container_name: planet_aiprovider + ports: + - "8010:8010" + postgres: image: postgres:15 container_name: planet_postgres diff --git a/docker-compose.yml b/docker-compose.yml index 98e91131..89ab58ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,19 @@ version: '3.8' services: + aiprovider: + build: + context: . + dockerfile: aiprovider/Dockerfile + container_name: planet_aiprovider + ports: + - "8010:8010" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8010/health"] + interval: 10s + timeout: 5s + retries: 5 + postgres: image: postgres:15 container_name: planet_postgres diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2881fc0a..0407a8de 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,37 @@ This project follows the repository versioning rule: - `feature` -> `+0.1.0` - `bugfix` -> `+0.0.1` +## 0.23.0 + +Released: 2026-04-07 + +### Highlights + +- Introduced a dedicated `aiprovider` service so the main backend now exposes stable AI business APIs while model-vendor integration lives behind an internal adapter boundary. +- Added multi-protocol model access for `openai-compatible`, `claude-compatible`, and native `ollama` local-model flows, including local startup templates and service-aware restart controls. +- Standardized Python runtime management on `uv` across backend and `aiprovider`, removing the old container-side `pip/requirements.txt` installation path. + +### Added + +- Added [backend/app/api/v1/ai.py](/home/ray/dev/linkong/planet/backend/app/api/v1/ai.py), exposing stable AI business endpoints for provider status and situational-awareness analysis. +- Added [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py), introducing an internal HTTP client for `backend -> aiprovider` calls with request-id propagation and lightweight retry. +- Added [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py), [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py), and related config/schema files to stand up the dedicated adapter service. +- Added [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) and [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) as ready-to-edit local-model templates. +- Added [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns. +- Added a dedicated `重启 AI Provider` control path in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx), [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py), and [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py). + +### Improved + +- Improved backend-to-provider tracing by propagating `X-Request-ID` through the AI call chain and returning the same header from both backend and `aiprovider`. +- Improved resilience by adding lightweight retry handling to both `backend -> aiprovider` and `aiprovider -> model provider` HTTP calls. +- Improved operator workflow by folding `aiprovider` startup, health checks, restart support, and log viewing into [planet.sh](/home/ray/dev/linkong/planet/planet.sh). +- Improved container consistency by switching [backend/Dockerfile](/home/ray/dev/linkong/planet/backend/Dockerfile) and [aiprovider/Dockerfile](/home/ray/dev/linkong/planet/aiprovider/Dockerfile) to `uv sync` / `uv run`. + +### Changed + +- Changed the repository Python dependency source of truth to `pyproject.toml + uv.lock`, and removed the old `backend/requirements.txt` path. +- Changed local restart wording in the dashboard from the vague `重启服务器` label to the more specific `重启后端`, reducing ambiguity once `aiprovider` became independently restartable. + ## 0.22.14 Released: 2026-04-07 diff --git a/docs/aiprovider.md b/docs/aiprovider.md new file mode 100644 index 00000000..64227453 --- /dev/null +++ b/docs/aiprovider.md @@ -0,0 +1,290 @@ +# AI Provider Guide + +## Overview + +`aiprovider` is the model-adapter service for Planet. + +It isolates model-vendor details from the main backend so the rest of the system can call a stable business API: + +- Caller service -> `planet backend` +- `planet backend` -> `aiprovider` +- `aiprovider` -> concrete model provider + +The recommended default is: + +- External and cross-service callers use `planet backend` +- Only infrastructure-grade internal jobs call `aiprovider` directly + +## Responsibilities + +`backend` is responsible for: + +- authentication and authorization +- business-level request shaping +- stable `/api/v1/ai/...` endpoints +- internal service-to-service authentication toward `aiprovider` + +`aiprovider` is responsible for: + +- model protocol adaptation +- provider selection by `.env` +- timeout and lightweight retry +- request tracing via `X-Request-ID` + +## Supported Providers + +`aiprovider` currently supports: + +- `openai` +- `openai_compatible` +- `anthropic` +- `anthropic_compatible` +- `claude_compatible` +- `ollama` + +Provider mapping: + +- `vLLM`, `LM Studio`, `One API`: `openai_compatible` +- `MiniMax`, Claude-compatible gateways: `claude_compatible` +- `Ollama`: `ollama` + +## API Surfaces + +### Main backend API + +Preferred stable entrypoints: + +- `GET /api/v1/ai/provider/status` +- `POST /api/v1/ai/situational-awareness/analyze` + +Authentication: + +- `Authorization: Bearer ` + +Optional tracing header: + +- `X-Request-ID: ` + +The backend will propagate `X-Request-ID` to `aiprovider` and return the same header in the response. + +### AI provider internal API + +Internal-only endpoints: + +- `GET /v1/provider/status` +- `POST /v1/analyze` + +Authentication: + +- `X-Provider-Token: ` + +Optional tracing header: + +- `X-Request-ID: ` + +## Request Example + +### Call through backend + +```bash +curl -X POST http://localhost:8000/api/v1/ai/situational-awareness/analyze \ + -H "Authorization: Bearer " \ + -H "X-Request-ID: bgp-incident-20260407-001" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "BGP异常研判", + "objective": "总结当前风险并给出处置建议", + "observations": [ + "collector A 在 5 分钟内出现多次 origin 变更", + "异常集中在同一地区前缀" + ], + "constraints": [ + "不要编造不存在的数据", + "区分事实和推断" + ], + "context": { + "source": "bgp-monitor", + "severity": "high" + } + }' +``` + +### Call `aiprovider` directly + +```bash +curl -X POST http://localhost:8010/v1/analyze \ + -H "X-Provider-Token: change_me" \ + -H "X-Request-ID: ai-batch-job-001" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "链路波动分析", + "objective": "给出简要态势摘要和下一步建议", + "observations": [ + "多个节点出现延迟上升" + ], + "constraints": [ + "不要假设根因已经确认" + ], + "context": { + "region": "APAC" + } + }' +``` + +## Response Shape + +Both backend and `aiprovider` return the same payload shape: + +```json +{ + "provider": "openai_compatible", + "model": "gpt-4o-mini", + "content": "1) 态势摘要 ...", + "raw_response": {} +} +``` + +Both services also return: + +- `X-Request-ID: ` + +## Configuration + +### Backend + +Recommended backend `.env`: + +```env +AI_PROVIDER_SERVICE_URL=http://localhost:8010 +AI_PROVIDER_SERVICE_TOKEN=change_me +AI_PROVIDER_TIMEOUT_SECONDS=60 +AI_PROVIDER_RETRY_ATTEMPTS=2 +``` + +Reference file: + +- [backend/.env.example](/home/ray/dev/linkong/planet/backend/.env.example) + +### AI Provider + +Reference file: + +- [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) + +Frontend local reference: + +- [frontend/.env.example](/home/ray/dev/linkong/planet/frontend/.env.example) + +Common settings: + +```env +SERVICE_NAME=planet-ai-provider +SERVICE_VERSION=0.1.0 +AI_PROVIDER_SERVICE_TOKEN=change_me +AI_TIMEOUT_SECONDS=60 +AI_HTTP_RETRY_ATTEMPTS=2 +AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。 +``` + +### OpenAI-compatible example + +```env +AI_PROVIDER=openai_compatible +AI_BASE_URL=http://127.0.0.1:8001/v1 +AI_API_KEY=local-key +AI_MODEL=your-local-model +``` + +### Claude-compatible example + +```env +AI_PROVIDER=claude_compatible +AI_BASE_URL=https://your-claude-compatible-endpoint.example.com +AI_API_KEY=your_api_key +AI_MODEL=your-model +AI_MAX_TOKENS=1200 +AI_ANTHROPIC_VERSION=2023-06-01 +``` + +### Ollama example + +```env +AI_PROVIDER=ollama +AI_BASE_URL=http://127.0.0.1:11434 +AI_API_KEY= +AI_MODEL=qwen2.5:7b +``` + +## Deployment Modes + +### Single machine + +Recommended local flow: + +- `backend` on `localhost:8000` +- `aiprovider` on `localhost:8010` +- local model gateway on `localhost:11434` or another local port + +Helpers already included: + +- [planet.sh](/home/ray/dev/linkong/planet/planet.sh) +- [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) + +### Multi-machine + +Example topology: + +- app machine: `backend` +- AI gateway machine: `aiprovider` +- model machine: local model service or cloud proxy + +In that case, this becomes service-to-service HTTP RPC: + +- caller -> backend +- backend -> `http://10.0.0.12:8010` +- `aiprovider` -> model endpoint + +Recommended cross-machine backend config: + +```env +AI_PROVIDER_SERVICE_URL=http://10.0.0.12:8010 +AI_PROVIDER_SERVICE_TOKEN=change_me +AI_PROVIDER_TIMEOUT_SECONDS=60 +AI_PROVIDER_RETRY_ATTEMPTS=2 +``` + +Recommended operating rules: + +- keep `aiprovider` on a private network +- protect it with `X-Provider-Token` at minimum +- always send `X-Request-ID` +- keep callers on the backend API unless they are infrastructure jobs + +## Retry And Failure Behavior + +`backend -> aiprovider`: + +- retries lightweight network / 5xx failures +- returns `502` when the provider service is unavailable + +`aiprovider -> model provider`: + +- retries lightweight network / 5xx failures +- returns `502` when the model provider is unavailable + +This is intentionally conservative. It avoids masking persistent errors while still absorbing short hiccups. + +## Operational Notes + +- `./planet.sh start` now starts `aiprovider` automatically +- `./planet.sh restart -a` restarts only `aiprovider` +- `./planet.sh log -a` tails `aiprovider` logs +- `./planet.sh health` reports `aiprovider` health + +## Recommended Calling Policy + +- Frontend and application services: call `backend` +- Scheduled infra jobs and diagnostics: optionally call `aiprovider` +- Do not let multiple business services integrate model vendors independently + +That keeps provider switching centralized and avoids model-specific drift across the system. diff --git a/docs/version-history.md b/docs/version-history.md index ff98911b..7af737b9 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,7 +16,7 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.22.9` +- `dev` 当前开发分支历史推导到:`0.23.0` ## Timeline @@ -70,6 +70,7 @@ | `0.21.5` | bugfix | `dev` | `a761dfc5` | refine Earth legend item presentation | | `0.21.6` | bugfix | `dev` | `pending` | improve Earth legend generation, info-card interactions, and HUD messaging polish | | `0.22.9` | bugfix | `dev` | `6bfcd053` | simplify `planet.sh` readiness messaging and only show retry counts on actual restart | +| `0.23.0` | feature | `dev` | `pending` | add dedicated `aiprovider` service, multi-protocol AI adapters, uv-only Python runtime, and AI Provider restart controls | ## Maintenance Commits Not Counted as Version Bumps diff --git a/frontend/.env.example b/frontend/.env.example index cbe85bfa..4c2868de 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,2 +1,3 @@ VITE_API_URL=/api/v1 -VITE_WS_URL=ws://localhost:8000/ws +VITE_WS_URL= +VITE_SA_GATEWAY=http diff --git a/frontend/package.json b/frontend/package.json index 827de604..75b87a17 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.22.14", + "version": "0.23.0", "private": true, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/frontend/src/pages/BGP/BGP.tsx b/frontend/src/pages/BGP/BGP.tsx index d904dd34..8dfc7a45 100644 --- a/frontend/src/pages/BGP/BGP.tsx +++ b/frontend/src/pages/BGP/BGP.tsx @@ -1,100 +1,20 @@ import { useEffect, useState } from 'react' import { Alert, Card, Col, Row, Space, Statistic, Table, Tag, Typography } from 'antd' -import axios from 'axios' import AppLayout from '../../components/AppLayout/AppLayout' import { formatDateTimeZhCN } from '../../utils/datetime' +import { + getSituationalAwarenessGateway, + type BGPAnomaly, + type BGPCollectorCoverage, + type BGPEvent, + type BGPIncident, + type CollectorSummary, + type EventSummary, + type Summary, +} from '../../services/situational-awareness' const { Title, Text } = Typography - -interface BGPAnomaly { - id: number - source: string - anomaly_type: string - severity: string - status: string - prefix: string | null - origin_asn: number | null - new_origin_asn: number | null - confidence: number - summary: string - created_at: string | null -} - -interface BGPEvent { - id: number - collector: string | null - event_type: string - prefix: string | null - origin_asn: number | null - peer_asn: number | null - observed_at: string | null -} - -interface BGPCollectorCoverage { - collector: string - city?: string | null - country?: string | null - observation_count: number - recent_24h_observation_count: number - recent_7d_observation_count: number - prefix_count: number - recent_24h_prefix_count: number - recent_7d_prefix_count: number - origin_asn_count: number - peer_asn_count: number - latest_observed_at: string | null - latest_event_type: string | null - baseline_scope: { - countries: string[] - cities: string[] - } -} - -interface BGPIncident { - id: number - incident_type: string - title: string - summary: string - severity: string - status: string - confidence: number - affected_prefixes: string[] - affected_asns: number[] - affected_collectors: string[] - affected_regions: Array<{ country?: string; city?: string }> - related_cables: Array<{ - landing_point?: string - city?: string - country?: string - distance_km?: number - cable_names?: string[] - }> - created_at: string | null - started_at: string | null -} - -interface Summary { - total: number - by_type: Record - by_severity: Record - by_status: Record -} - -interface EventSummary { - total: number - collector_count: number - prefix_count: number - by_type: Record -} - -interface CollectorSummary { - total: number - active_collectors: number - observed_prefixes: number - observed_origins: number - recent_24h_events: number - recent_7d_events: number -} +const situationalAwarenessGateway = getSituationalAwarenessGateway() function severityColor(severity: string) { if (severity === 'critical') return 'red' @@ -117,22 +37,20 @@ function BGP() { const load = async () => { setLoading(true) try { - const [incidentsRes, incidentSummaryRes, anomaliesRes, eventsRes, eventSummaryRes, collectorsRes, collectorSummaryRes] = await Promise.all([ - axios.get('/api/v1/bgp/incidents', { params: { page_size: 50 } }), - axios.get('/api/v1/bgp/incidents/summary'), - axios.get('/api/v1/bgp/anomalies', { params: { page_size: 100 } }), - axios.get('/api/v1/bgp/events', { params: { page_size: 20 } }), - axios.get('/api/v1/bgp/events/summary'), - axios.get('/api/v1/bgp/collectors'), - axios.get('/api/v1/bgp/collectors/summary'), - ]) - setIncidents(incidentsRes.data.data || []) - setIncidentSummary(incidentSummaryRes.data) - setAnomalies(anomaliesRes.data.data || []) - setEvents(eventsRes.data.data || []) - setEventSummary(eventSummaryRes.data) - setCollectors(collectorsRes.data.data || []) - setCollectorSummary(collectorSummaryRes.data) + const snapshot = await situationalAwarenessGateway.getBGPOverview({ + incidentPageSize: 50, + anomalyPageSize: 100, + eventPageSize: 20, + }) + setIncidents(snapshot.incidents) + setIncidentSummary(snapshot.incidentSummary) + setAnomalies(snapshot.anomalies) + setEvents(snapshot.events) + setEventSummary(snapshot.eventSummary) + setCollectors(snapshot.collectors) + setCollectorSummary(snapshot.collectorSummary) + } catch (error) { + console.error('Failed to load BGP overview:', error) } finally { setLoading(false) } diff --git a/frontend/src/pages/Dashboard/Dashboard.tsx b/frontend/src/pages/Dashboard/Dashboard.tsx index 9b9433a7..4f70b910 100644 --- a/frontend/src/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/pages/Dashboard/Dashboard.tsx @@ -47,16 +47,22 @@ interface RestartTaskLogs { lines: string[] } -type RestartAction = 'restart-backend' | 'restart-database' | 'restart-system' +type RestartAction = 'restart-backend' | 'restart-ai-provider' | 'restart-database' | 'restart-system' type RestartStage = 'confirming' | 'waiting_for_shutdown' | 'waiting_for_recovery' | 'recovered' | 'failed' | 'timeout' const RESTART_ACTION_OPTIONS: Array<{ value: RestartAction; label: string; description: string; command: string }> = [ { value: 'restart-backend', - label: '重启服务器', + label: '重启后端', description: '只重启后端服务,页面通常会短暂失联后自动恢复。', command: './planet.sh restart -b', }, + { + value: 'restart-ai-provider', + label: '重启 AI Provider', + description: '只重启 AI Provider 适配服务,前端页面通常保持在线。', + command: './planet.sh restart -a', + }, { value: 'restart-database', label: '重启数据库', @@ -77,6 +83,11 @@ const RESTART_GUIDE_LINES: Record = { '[ctl] handing restart to detached runner', '[ctl] waiting for backend health recovery', ], + 'restart-ai-provider': [ + '[ctl] preparing ai provider restart task', + '[ctl] handing restart to detached runner', + '[ctl] waiting for ai provider health recovery', + ], 'restart-database': [ '[ctl] preparing database restart task', '[ctl] restarting PostgreSQL and Redis containers', @@ -94,6 +105,9 @@ const RESTART_GUIDE_LINES: Record = { let cachedDashboardStats: Stats | null = null function getRestartConfirmMessage(action: RestartAction): string { + if (action === 'restart-ai-provider') { + return '将重启 AI Provider 适配服务,页面通常保持在线,但 AI 分析请求会短暂不可用。' + } if (action === 'restart-database') { return '将重启 PostgreSQL 和 Redis,页面通常保持在线,但相关请求可能短暂波动。' } @@ -200,6 +214,8 @@ function Dashboard() { setRestartMessage( restartAction === 'restart-system' ? '已发送完全重启指令,页面可能暂时失联,恢复后会自动刷新。' + : restartAction === 'restart-ai-provider' + ? '已发送 AI Provider 重启指令,正在等待 AI 服务恢复。' : '已发送重启指令,正在等待服务进入重启流程。' ) setRestartLogs((current) => [...current, `任务已创建: ${res.data.task_id}`]) diff --git a/frontend/src/services/situational-awareness/http-gateway.ts b/frontend/src/services/situational-awareness/http-gateway.ts new file mode 100644 index 00000000..b64742f5 --- /dev/null +++ b/frontend/src/services/situational-awareness/http-gateway.ts @@ -0,0 +1,46 @@ +import axios from 'axios' +import type { SituationalAwarenessGateway } from './port' +import type { + BGPAnomaly, + BGPCollectorCoverage, + BGPEvent, + BGPIncident, + BGPOverviewOptions, + BGPOverviewSnapshot, + CollectorSummary, + EventSummary, + ListResponse, + Summary, +} from './types' + +const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1' + +export class HttpSituationalAwarenessGateway implements SituationalAwarenessGateway { + async getBGPOverview(options: BGPOverviewOptions = {}): Promise { + const { + incidentPageSize = 50, + anomalyPageSize = 100, + eventPageSize = 20, + } = options + + const [incidentsRes, incidentSummaryRes, anomaliesRes, eventsRes, eventSummaryRes, collectorsRes, collectorSummaryRes] = await Promise.all([ + axios.get>(`${API_BASE_URL}/bgp/incidents`, { params: { page_size: incidentPageSize } }), + axios.get(`${API_BASE_URL}/bgp/incidents/summary`), + axios.get>(`${API_BASE_URL}/bgp/anomalies`, { params: { page_size: anomalyPageSize } }), + axios.get>(`${API_BASE_URL}/bgp/events`, { params: { page_size: eventPageSize } }), + axios.get(`${API_BASE_URL}/bgp/events/summary`), + axios.get>(`${API_BASE_URL}/bgp/collectors`), + axios.get(`${API_BASE_URL}/bgp/collectors/summary`), + ]) + + return { + incidents: incidentsRes.data.data || [], + incidentSummary: incidentSummaryRes.data, + anomalies: anomaliesRes.data.data || [], + events: eventsRes.data.data || [], + eventSummary: eventSummaryRes.data, + collectors: collectorsRes.data.data || [], + collectorSummary: collectorSummaryRes.data, + } + } +} diff --git a/frontend/src/services/situational-awareness/index.ts b/frontend/src/services/situational-awareness/index.ts new file mode 100644 index 00000000..3e62a6e0 --- /dev/null +++ b/frontend/src/services/situational-awareness/index.ts @@ -0,0 +1,23 @@ +import type { SituationalAwarenessGateway } from './port' +import { HttpSituationalAwarenessGateway } from './http-gateway' +import { MockSituationalAwarenessGateway } from './mock-gateway' + +export * from './types' +export type { SituationalAwarenessGateway } from './port' + +let singleton: SituationalAwarenessGateway | null = null + +export function createSituationalAwarenessGateway(): SituationalAwarenessGateway { + const provider = (import.meta as any).env?.VITE_SA_GATEWAY || 'http' + if (provider === 'mock') { + return new MockSituationalAwarenessGateway() + } + return new HttpSituationalAwarenessGateway() +} + +export function getSituationalAwarenessGateway(): SituationalAwarenessGateway { + if (!singleton) { + singleton = createSituationalAwarenessGateway() + } + return singleton +} diff --git a/frontend/src/services/situational-awareness/mock-gateway.ts b/frontend/src/services/situational-awareness/mock-gateway.ts new file mode 100644 index 00000000..9ac899bf --- /dev/null +++ b/frontend/src/services/situational-awareness/mock-gateway.ts @@ -0,0 +1,35 @@ +import type { SituationalAwarenessGateway } from './port' +import type { BGPOverviewOptions, BGPOverviewSnapshot } from './types' + +const EMPTY_SNAPSHOT: BGPOverviewSnapshot = { + incidents: [], + incidentSummary: { + total: 0, + by_type: {}, + by_severity: {}, + by_status: {}, + }, + anomalies: [], + events: [], + eventSummary: { + total: 0, + collector_count: 0, + prefix_count: 0, + by_type: {}, + }, + collectors: [], + collectorSummary: { + total: 0, + active_collectors: 0, + observed_prefixes: 0, + observed_origins: 0, + recent_24h_events: 0, + recent_7d_events: 0, + }, +} + +export class MockSituationalAwarenessGateway implements SituationalAwarenessGateway { + async getBGPOverview(_options: BGPOverviewOptions = {}): Promise { + return EMPTY_SNAPSHOT + } +} diff --git a/frontend/src/services/situational-awareness/port.ts b/frontend/src/services/situational-awareness/port.ts new file mode 100644 index 00000000..358b7dde --- /dev/null +++ b/frontend/src/services/situational-awareness/port.ts @@ -0,0 +1,5 @@ +import type { BGPOverviewOptions, BGPOverviewSnapshot } from './types' + +export interface SituationalAwarenessGateway { + getBGPOverview(options?: BGPOverviewOptions): Promise +} diff --git a/frontend/src/services/situational-awareness/types.ts b/frontend/src/services/situational-awareness/types.ts new file mode 100644 index 00000000..41683b7e --- /dev/null +++ b/frontend/src/services/situational-awareness/types.ts @@ -0,0 +1,112 @@ +export interface BGPAnomaly { + id: number + source: string + anomaly_type: string + severity: string + status: string + prefix: string | null + origin_asn: number | null + new_origin_asn: number | null + confidence: number + summary: string + created_at: string | null +} + +export interface BGPEvent { + id: number + collector: string | null + event_type: string + prefix: string | null + origin_asn: number | null + peer_asn: number | null + observed_at: string | null +} + +export interface BGPCollectorCoverage { + collector: string + city?: string | null + country?: string | null + observation_count: number + recent_24h_observation_count: number + recent_7d_observation_count: number + prefix_count: number + recent_24h_prefix_count: number + recent_7d_prefix_count: number + origin_asn_count: number + peer_asn_count: number + latest_observed_at: string | null + latest_event_type: string | null + baseline_scope: { + countries: string[] + cities: string[] + } +} + +export interface BGPIncident { + id: number + incident_type: string + title: string + summary: string + severity: string + status: string + confidence: number + affected_prefixes: string[] + affected_asns: number[] + affected_collectors: string[] + affected_regions: Array<{ country?: string; city?: string }> + related_cables: Array<{ + landing_point?: string + city?: string + country?: string + distance_km?: number + cable_names?: string[] + }> + created_at: string | null + started_at: string | null +} + +export interface Summary { + total: number + by_type: Record + by_severity: Record + by_status: Record +} + +export interface EventSummary { + total: number + collector_count: number + prefix_count: number + by_type: Record +} + +export interface CollectorSummary { + total: number + active_collectors: number + observed_prefixes: number + observed_origins: number + recent_24h_events: number + recent_7d_events: number +} + +export interface ListResponse { + total: number + page?: number + page_size?: number + data: T[] +} + +export interface BGPOverviewSnapshot { + incidents: BGPIncident[] + incidentSummary: Summary | null + anomalies: BGPAnomaly[] + events: BGPEvent[] + eventSummary: EventSummary | null + collectors: BGPCollectorCoverage[] + collectorSummary: CollectorSummary | null +} + +export interface BGPOverviewOptions { + incidentPageSize?: number + anomalyPageSize?: number + eventPageSize?: number +} diff --git a/planet.sh b/planet.sh index 88b91425..b487a830 100755 --- a/planet.sh +++ b/planet.sh @@ -14,11 +14,22 @@ NC='\033[0m' BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}" BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}" BACKEND_HEALTH_CHECK_INTERVAL="${BACKEND_HEALTH_CHECK_INTERVAL:-2}" +AI_PROVIDER_HEALTH_CHECK_ATTEMPTS="${AI_PROVIDER_HEALTH_CHECK_ATTEMPTS:-10}" +AI_PROVIDER_HEALTH_CHECK_INTERVAL="${AI_PROVIDER_HEALTH_CHECK_INTERVAL:-2}" FRONTEND_MAX_RETRIES="${FRONTEND_MAX_RETRIES:-3}" FRONTEND_HEALTH_CHECK_ATTEMPTS="${FRONTEND_HEALTH_CHECK_ATTEMPTS:-10}" FRONTEND_HEALTH_CHECK_INTERVAL="${FRONTEND_HEALTH_CHECK_INTERVAL:-2}" DEFAULT_BACKEND_PORT="${DEFAULT_BACKEND_PORT:-8000}" DEFAULT_FRONTEND_PORT="${DEFAULT_FRONTEND_PORT:-3000}" +DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}" + +compose_up() { + if docker compose version >/dev/null 2>&1; then + docker compose "$@" + else + docker-compose "$@" + fi +} ensure_uv_backend_deps() { echo -e "${BLUE}📦 检查后端 uv 环境...${NC}" @@ -104,6 +115,18 @@ start_backend_with_retry() { return 1 } +start_ai_provider_service() { + local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" + + echo -e "${BLUE}🧠 启动 AI Provider...${NC}" + docker start planet_aiprovider 2>/dev/null || compose_up up -d aiprovider + + if ! wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then + echo -e "${RED}❌ AI Provider 启动失败${NC}" + exit 1 + fi +} + start_frontend_with_retry() { local frontend_port="$1" local retry=1 @@ -166,8 +189,10 @@ kill_port_if_requested() { parse_service_args() { BACKEND_PORT="$DEFAULT_BACKEND_PORT" FRONTEND_PORT="$DEFAULT_FRONTEND_PORT" + AI_PROVIDER_PORT="$DEFAULT_AI_PROVIDER_PORT" BACKEND_PORT_REQUESTED=0 FRONTEND_PORT_REQUESTED=0 + AI_PROVIDER_REQUESTED=0 DATABASE_REQUESTED=0 while [ "$#" -gt 0 ]; do @@ -190,6 +215,15 @@ parse_service_args() { shift 1 fi ;; + -a|--ai-provider-port) + AI_PROVIDER_REQUESTED=1 + if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then + AI_PROVIDER_PORT="$2" + shift 2 + else + shift 1 + fi + ;; -d|--database) DATABASE_REQUESTED=1 shift 1 @@ -203,6 +237,7 @@ parse_service_args() { validate_port "$BACKEND_PORT" validate_port "$FRONTEND_PORT" + validate_port "$AI_PROVIDER_PORT" } cleanup_exit_containers() { @@ -219,6 +254,10 @@ stop_backend_service() { pkill -f "uvicorn" 2>/dev/null || true } +stop_ai_provider_service() { + docker stop planet_aiprovider 2>/dev/null || true +} + stop_frontend_service() { pkill -f "vite" 2>/dev/null || true pkill -f "bun run dev" 2>/dev/null || true @@ -226,18 +265,21 @@ stop_frontend_service() { restart_database_service() { echo -e "${BLUE}🗄️ 重启数据库...${NC}" - docker restart planet_postgres planet_redis 2>/dev/null || docker-compose up -d postgres redis + docker restart planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis sleep 3 } start_backend_service() { local backend_port="$1" local backend_port_requested="$2" + local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}" echo -e "${BLUE}🗄️ 启动数据库...${NC}" - docker start planet_postgres planet_redis 2>/dev/null || docker-compose up -d postgres redis + docker start planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis sleep 3 + start_ai_provider_service "$ai_provider_port" + if [ "$backend_port_requested" -eq 1 ]; then kill_port_if_requested "$backend_port" "后端" fi @@ -280,7 +322,7 @@ create_user() { ensure_uv_backend_deps echo -e "${BLUE}🗄️ 启动数据库...${NC}" - docker start planet_postgres 2>/dev/null || docker-compose up -d postgres + docker start planet_postgres 2>/dev/null || compose_up up -d postgres sleep 2 echo -e "${BLUE}👤 创建用户${NC}" @@ -379,18 +421,20 @@ start() { echo -e "${BLUE}🚀 启动智能星球计划...${NC}" - start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" + start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT" start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" echo "" echo -e "${GREEN}✅ 启动完成!${NC}" echo " 前端: http://localhost:${FRONTEND_PORT}" echo " 后端: http://localhost:${BACKEND_PORT}" + echo " AI Provider: http://localhost:${AI_PROVIDER_PORT}" } stop() { echo -e "${YELLOW}🛑 停止服务...${NC}" stop_backend_service + stop_ai_provider_service stop_frontend_service docker stop planet_postgres planet_redis 2>/dev/null || true echo -e "${GREEN}✅ 已停止${NC}" @@ -400,7 +444,7 @@ restart() { parse_service_args "$@" cleanup_exit_containers - if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then + if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then stop sleep 1 start @@ -413,10 +457,16 @@ restart() { restart_database_service fi + if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then + stop_ai_provider_service + sleep 1 + start_ai_provider_service "$AI_PROVIDER_PORT" + fi + if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then stop_backend_service sleep 1 - start_backend_service "$BACKEND_PORT" 1 + start_backend_service "$BACKEND_PORT" 1 "$AI_PROVIDER_PORT" fi if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then @@ -430,6 +480,9 @@ restart() { if [ "$DATABASE_REQUESTED" -eq 1 ]; then echo " 数据库: planet_postgres, planet_redis" fi + if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then + echo " AI Provider: http://localhost:${AI_PROVIDER_PORT}" + fi if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then echo " 后端: http://localhost:${BACKEND_PORT}" fi @@ -450,6 +503,12 @@ health() { echo -e " 后端: ${RED}❌ 未运行${NC}" fi + if curl -s "http://localhost:${DEFAULT_AI_PROVIDER_PORT}/health" > /dev/null 2>&1; then + echo -e " AI Provider: ${GREEN}✅ 运行中${NC}" + else + echo -e " AI Provider: ${RED}❌ 未运行${NC}" + fi + if curl -s http://localhost:3000 > /dev/null 2>&1; then echo -e " 前端: ${GREEN}✅ 运行中${NC}" else @@ -467,10 +526,16 @@ log() { echo "📝 后端日志 (Ctrl+C 退出):" tail -f /tmp/planet_backend.log ;; + -a|--ai-provider) + echo "📝 AI Provider 日志 (Ctrl+C 退出):" + docker logs -f planet_aiprovider + ;; *) echo "📝 最近日志:" echo "--- 后端 ---" tail -20 /tmp/planet_backend.log 2>/dev/null || echo "无日志" + echo "--- AI Provider ---" + docker logs --tail 20 planet_aiprovider 2>/dev/null || echo "无日志" echo "--- 前端 ---" tail -20 /tmp/planet_frontend.log 2>/dev/null || echo "无日志" ;; @@ -502,13 +567,14 @@ case "$1" in echo "用法: ./planet.sh {start|stop|restart|createuser|health|log}" echo "" echo "命令:" - echo " start 启动服务,可选: -b <后端端口> -f <前端端口>" + echo " start 启动服务,可选: -b <后端端口> -f <前端端口> -a " echo " stop 停止服务" - echo " restart 重启服务,可选: -b [后端端口] -f [前端端口] -d" + echo " restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d" echo " createuser 交互创建用户" echo " health 检查健康状态" echo " log 查看日志" echo " log -f 查看前端日志" echo " log -b 查看后端日志" + echo " log -a 查看 AI Provider 日志" ;; esac diff --git a/project_context.md b/project_context.md index ad010f79..ce1d4f98 100644 --- a/project_context.md +++ b/project_context.md @@ -99,7 +99,8 @@ │ │ ├── api/ │ │ ├── unit/ │ │ └── conftest.py -│ ├── requirements.txt +│ ├── pyproject.toml +│ ├── uv.lock │ └── alembic/ │ ├── frontend/ # React Admin @@ -192,10 +193,10 @@ ### Backend ```bash +uv sync --group dev cd backend -pip install -r requirements.txt -python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 -docker-compose up -d backend +uv run --project .. python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +docker compose up -d backend ruff check . && black --check . pytest -v && pytest tests/api/test_auth.py::test_login -v ``` diff --git a/pyproject.toml b/pyproject.toml index 3899da6a..621946fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.22.14" +version = "0.23.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/rules.md b/rules.md index a380595e..99705a68 100644 --- a/rules.md +++ b/rules.md @@ -155,7 +155,7 @@ git fetch origin && git rebase origin/main **Rules:** - Verify package legitimacy before adding - Prefer well-maintained, widely-used libraries -- Pin dependency versions in `requirements.txt` and `package.json` +- Pin dependency versions in `pyproject.toml`, `uv.lock`, and `package.json` - Review security advisories with `pip-audit` and `bun audit` - **NEVER** add unknown packages diff --git a/scripts/bootstrap-dev.sh b/scripts/bootstrap-dev.sh new file mode 100755 index 00000000..3f0da561 --- /dev/null +++ b/scripts/bootstrap-dev.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +BLUE='\033[0;34m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +have_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +ensure_local_bin_on_path() { + export PATH="$HOME/.local/bin:$PATH" + export PATH="$HOME/.bun/bin:$PATH" +} + +install_uv_if_needed() { + if have_cmd uv; then + echo -e "${GREEN}uv 已存在: $(command -v uv)${NC}" + return + fi + + echo -e "${BLUE}安装 uv...${NC}" + curl -LsSf https://astral.sh/uv/install.sh | sh + ensure_local_bin_on_path + + if ! have_cmd uv; then + echo -e "${RED}uv 安装失败,请手动检查 ~/.local/bin 是否加入 PATH${NC}" + exit 1 + fi +} + +install_bun_if_needed() { + if have_cmd bun; then + echo -e "${GREEN}bun 已存在: $(command -v bun)${NC}" + return + fi + + echo -e "${BLUE}安装 bun...${NC}" + curl -fsSL https://bun.sh/install | bash + ensure_local_bin_on_path + + if ! have_cmd bun; then + echo -e "${RED}bun 安装失败,请手动检查 ~/.bun/bin 是否加入 PATH${NC}" + exit 1 + fi +} + +ensure_python_alias() { + mkdir -p "$HOME/.local/bin" + if [ ! -e "$HOME/.local/bin/python" ]; then + ln -sf /usr/bin/python3 "$HOME/.local/bin/python" + fi + ensure_local_bin_on_path +} + +sync_python_env() { + echo -e "${BLUE}同步 Python 依赖...${NC}" + cd "$ROOT_DIR" + uv python install 3.14 + uv sync --group dev +} + +sync_frontend_env() { + echo -e "${BLUE}同步前端依赖...${NC}" + cd "$ROOT_DIR/frontend" + bun install +} + +ensure_env_files() { + echo -e "${BLUE}检查环境变量模板...${NC}" + + if [ ! -f "$ROOT_DIR/backend/.env" ] && [ -f "$ROOT_DIR/backend/.env.example" ]; then + cp "$ROOT_DIR/backend/.env.example" "$ROOT_DIR/backend/.env" + echo -e "${YELLOW}已创建 backend/.env,请按需修改 SECRET_KEY / 数据库 / AI 服务配置${NC}" + fi + + if [ ! -f "$ROOT_DIR/aiprovider/.env" ] && [ -f "$ROOT_DIR/aiprovider/.env.example" ]; then + cp "$ROOT_DIR/aiprovider/.env.example" "$ROOT_DIR/aiprovider/.env" + echo -e "${YELLOW}已创建 aiprovider/.env,请按需修改 provider/model/api key${NC}" + fi + + if [ ! -f "$ROOT_DIR/frontend/.env.local" ] && [ -f "$ROOT_DIR/frontend/.env.example" ]; then + cp "$ROOT_DIR/frontend/.env.example" "$ROOT_DIR/frontend/.env.local" + echo -e "${YELLOW}已创建 frontend/.env.local,可按需覆盖 VITE_API_URL / VITE_WS_URL${NC}" + fi +} + +report_optional_tools() { + if have_cmd docker; then + echo -e "${GREEN}docker 已存在: $(command -v docker)${NC}" + else + echo -e "${YELLOW}未检测到 docker。如需容器方式启动 backend/aiprovider,请安装 Docker。${NC}" + fi +} + +print_next_steps() { + echo "" + echo -e "${GREEN}开发环境引导完成${NC}" + echo "下一步建议:" + echo " 1. cd \"$ROOT_DIR\"" + echo " 2. uv run pytest backend/tests/test_api.py -q -s" + echo " 3. ./planet.sh start" +} + +main() { + ensure_local_bin_on_path + install_uv_if_needed + install_bun_if_needed + ensure_python_alias + sync_python_env + sync_frontend_env + ensure_env_files + report_optional_tools + print_next_steps +} + +main "$@" diff --git a/uv.lock b/uv.lock index 43cc7548..2604d004 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.22.14" +version = "0.23.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" },