Merge branch 'codex/aiprovider-foundation' into dev
This commit is contained in:
83
README.md
83
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
|
||||
|
||||
待定
|
||||
|
||||
34
aiprovider/.env.example
Normal file
34
aiprovider/.env.example
Normal file
@@ -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
|
||||
23
aiprovider/Dockerfile
Normal file
23
aiprovider/Dockerfile
Normal file
@@ -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"]
|
||||
81
aiprovider/README.md
Normal file
81
aiprovider/README.md
Normal file
@@ -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`
|
||||
1
aiprovider/__init__.py
Normal file
1
aiprovider/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""AI provider adapter service package."""
|
||||
35
aiprovider/config.py
Normal file
35
aiprovider/config.py
Normal file
@@ -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()
|
||||
79
aiprovider/main.py
Normal file
79
aiprovider/main.py
Normal file
@@ -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)
|
||||
240
aiprovider/provider_service.py
Normal file
240
aiprovider/provider_service.py
Normal file
@@ -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 ""
|
||||
27
aiprovider/schemas.py
Normal file
27
aiprovider/schemas.py
Normal file
@@ -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
|
||||
@@ -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=
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
39
backend/app/api/v1/ai.py
Normal file
39
backend/app/api/v1/ai.py
Normal file
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
27
backend/app/schemas/ai.py
Normal file
27
backend/app/schemas/ai.py
Normal file
@@ -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
|
||||
109
backend/app/services/ai_client.py
Normal file
109
backend/app/services/ai_client.py
Normal file
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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":
|
||||
|
||||
@@ -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()
|
||||
|
||||
42
docker-compose.local-model.yml
Normal file
42
docker-compose.local-model.yml
Normal file
@@ -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:
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
290
docs/aiprovider.md
Normal file
290
docs/aiprovider.md
Normal file
@@ -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 <jwt>`
|
||||
|
||||
Optional tracing header:
|
||||
|
||||
- `X-Request-ID: <caller-generated-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: <shared-secret>`
|
||||
|
||||
Optional tracing header:
|
||||
|
||||
- `X-Request-ID: <caller-generated-id>`
|
||||
|
||||
## Request Example
|
||||
|
||||
### Call through backend
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/ai/situational-awareness/analyze \
|
||||
-H "Authorization: Bearer <access_token>" \
|
||||
-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: <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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
VITE_API_URL=/api/v1
|
||||
VITE_WS_URL=ws://localhost:8000/ws
|
||||
VITE_WS_URL=
|
||||
VITE_SA_GATEWAY=http
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.22.14",
|
||||
"version": "0.23.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
|
||||
@@ -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<string, number>
|
||||
by_severity: Record<string, number>
|
||||
by_status: Record<string, number>
|
||||
}
|
||||
|
||||
interface EventSummary {
|
||||
total: number
|
||||
collector_count: number
|
||||
prefix_count: number
|
||||
by_type: Record<string, number>
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<RestartAction, string[]> = {
|
||||
'[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<RestartAction, string[]> = {
|
||||
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}`])
|
||||
|
||||
46
frontend/src/services/situational-awareness/http-gateway.ts
Normal file
46
frontend/src/services/situational-awareness/http-gateway.ts
Normal file
@@ -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<BGPOverviewSnapshot> {
|
||||
const {
|
||||
incidentPageSize = 50,
|
||||
anomalyPageSize = 100,
|
||||
eventPageSize = 20,
|
||||
} = options
|
||||
|
||||
const [incidentsRes, incidentSummaryRes, anomaliesRes, eventsRes, eventSummaryRes, collectorsRes, collectorSummaryRes] = await Promise.all([
|
||||
axios.get<ListResponse<BGPIncident>>(`${API_BASE_URL}/bgp/incidents`, { params: { page_size: incidentPageSize } }),
|
||||
axios.get<Summary>(`${API_BASE_URL}/bgp/incidents/summary`),
|
||||
axios.get<ListResponse<BGPAnomaly>>(`${API_BASE_URL}/bgp/anomalies`, { params: { page_size: anomalyPageSize } }),
|
||||
axios.get<ListResponse<BGPEvent>>(`${API_BASE_URL}/bgp/events`, { params: { page_size: eventPageSize } }),
|
||||
axios.get<EventSummary>(`${API_BASE_URL}/bgp/events/summary`),
|
||||
axios.get<ListResponse<BGPCollectorCoverage>>(`${API_BASE_URL}/bgp/collectors`),
|
||||
axios.get<CollectorSummary>(`${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,
|
||||
}
|
||||
}
|
||||
}
|
||||
23
frontend/src/services/situational-awareness/index.ts
Normal file
23
frontend/src/services/situational-awareness/index.ts
Normal file
@@ -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
|
||||
}
|
||||
35
frontend/src/services/situational-awareness/mock-gateway.ts
Normal file
35
frontend/src/services/situational-awareness/mock-gateway.ts
Normal file
@@ -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<BGPOverviewSnapshot> {
|
||||
return EMPTY_SNAPSHOT
|
||||
}
|
||||
}
|
||||
5
frontend/src/services/situational-awareness/port.ts
Normal file
5
frontend/src/services/situational-awareness/port.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { BGPOverviewOptions, BGPOverviewSnapshot } from './types'
|
||||
|
||||
export interface SituationalAwarenessGateway {
|
||||
getBGPOverview(options?: BGPOverviewOptions): Promise<BGPOverviewSnapshot>
|
||||
}
|
||||
112
frontend/src/services/situational-awareness/types.ts
Normal file
112
frontend/src/services/situational-awareness/types.ts
Normal file
@@ -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<string, number>
|
||||
by_severity: Record<string, number>
|
||||
by_status: Record<string, number>
|
||||
}
|
||||
|
||||
export interface EventSummary {
|
||||
total: number
|
||||
collector_count: number
|
||||
prefix_count: number
|
||||
by_type: Record<string, number>
|
||||
}
|
||||
|
||||
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<T> {
|
||||
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
|
||||
}
|
||||
82
planet.sh
82
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 <AI Provider 端口>"
|
||||
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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.22.14"
|
||||
version = "0.23.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
2
rules.md
2
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
|
||||
|
||||
|
||||
124
scripts/bootstrap-dev.sh
Executable file
124
scripts/bootstrap-dev.sh
Executable file
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user