feat: ship persistent ai playground and alerts foundation
This commit is contained in:
@@ -332,6 +332,7 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
|||||||
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
|
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
|
||||||
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md)
|
- [docs/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/frontend-layout-guidelines.md)
|
||||||
- [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md)
|
- [docs/ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/ai-playground-development-plan.md)
|
||||||
|
- [docs/situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/situational-awareness-foundation-plan.md)
|
||||||
|
|
||||||
## 前端页面布局规范
|
## 前端页面布局规范
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,25 @@ from app.db.session import get_db
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.ai import (
|
from app.schemas.ai import (
|
||||||
AIProviderStatusResponse,
|
AIProviderStatusResponse,
|
||||||
|
AlertBriefRequest,
|
||||||
|
AlertBriefResponse,
|
||||||
BGPBriefRequest,
|
BGPBriefRequest,
|
||||||
BGPBriefRecordResponse,
|
BGPBriefRecordResponse,
|
||||||
BGPBriefRecordSummary,
|
BGPBriefRecordSummary,
|
||||||
|
PlaygroundMessageActionResponse,
|
||||||
|
PlaygroundMessageCreateRequest,
|
||||||
|
PlaygroundMessageEditRequest,
|
||||||
|
PlaygroundMessageResendRequest,
|
||||||
|
PlaygroundMessageStopRequest,
|
||||||
|
PlaygroundSessionResponse,
|
||||||
|
PlaygroundSessionUpsertRequest,
|
||||||
|
PlaygroundThreadResponse,
|
||||||
|
SituationalAlertBriefRequest,
|
||||||
|
SituationalAlertBriefResponse,
|
||||||
SituationalAnalysisRequest,
|
SituationalAnalysisRequest,
|
||||||
SituationalAnalysisResponse,
|
SituationalAnalysisResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.alert_ai_brief import build_alert_brief_request
|
||||||
from app.services.ai_client import AIProviderClient, get_ai_provider_client
|
from app.services.ai_client import AIProviderClient, get_ai_provider_client
|
||||||
from app.services.bgp_ai_brief import build_bgp_brief_request
|
from app.services.bgp_ai_brief import build_bgp_brief_request
|
||||||
from app.services.bgp_ai_brief_store import (
|
from app.services.bgp_ai_brief_store import (
|
||||||
@@ -22,6 +35,18 @@ from app.services.bgp_ai_brief_store import (
|
|||||||
list_bgp_brief_records,
|
list_bgp_brief_records,
|
||||||
save_bgp_brief_record,
|
save_bgp_brief_record,
|
||||||
)
|
)
|
||||||
|
from app.services.playground_session_store import (
|
||||||
|
get_playground_session,
|
||||||
|
upsert_playground_session,
|
||||||
|
)
|
||||||
|
from app.services.playground_chat_service import (
|
||||||
|
create_turn,
|
||||||
|
edit_user_message,
|
||||||
|
get_thread,
|
||||||
|
resend_turn,
|
||||||
|
stop_message,
|
||||||
|
)
|
||||||
|
from app.services.situational_alert_ai_brief import build_situational_alert_brief_request
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -51,6 +76,101 @@ async def analyze_situational_awareness(
|
|||||||
return await provider_client.analyze(payload, request_id=request_id)
|
return await provider_client.analyze(payload, request_id=request_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/playground/thread", response_model=PlaygroundThreadResponse | None)
|
||||||
|
async def get_playground_thread(
|
||||||
|
session_key: str = "default",
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await get_thread(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
session_key=session_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/playground/session", response_model=PlaygroundSessionResponse | None)
|
||||||
|
async def get_saved_playground_session(
|
||||||
|
session_key: str = "default",
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await get_playground_session(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
session_key=session_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/playground/session", response_model=PlaygroundSessionResponse)
|
||||||
|
async def save_playground_session(
|
||||||
|
payload: PlaygroundSessionUpsertRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await upsert_playground_session(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/playground/messages", response_model=PlaygroundMessageActionResponse)
|
||||||
|
async def create_playground_message(
|
||||||
|
payload: PlaygroundMessageCreateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
provider_client: AIProviderClient = Depends(get_ai_provider_client),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await create_turn(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
payload=payload,
|
||||||
|
provider_client=provider_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/playground/messages/stop", response_model=PlaygroundMessageActionResponse)
|
||||||
|
async def stop_playground_message(
|
||||||
|
payload: PlaygroundMessageStopRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await stop_message(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/playground/messages/resend", response_model=PlaygroundMessageActionResponse)
|
||||||
|
async def resend_playground_message(
|
||||||
|
payload: PlaygroundMessageResendRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
provider_client: AIProviderClient = Depends(get_ai_provider_client),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await resend_turn(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
payload=payload,
|
||||||
|
provider_client=provider_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/playground/messages/edit", response_model=PlaygroundMessageActionResponse)
|
||||||
|
async def edit_playground_message(
|
||||||
|
payload: PlaygroundMessageEditRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await edit_user_message(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/bgp/briefs", response_model=list[BGPBriefRecordSummary])
|
@router.get("/bgp/briefs", response_model=list[BGPBriefRecordSummary])
|
||||||
async def list_saved_bgp_briefs(
|
async def list_saved_bgp_briefs(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
@@ -88,7 +208,7 @@ async def analyze_bgp_brief(
|
|||||||
request_id = request.headers.get("X-Request-ID") or str(uuid4())
|
request_id = request.headers.get("X-Request-ID") or str(uuid4())
|
||||||
response.headers["X-Request-ID"] = request_id
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
|
||||||
brief_request = await build_bgp_brief_request(
|
brief_request, facts, context = await build_bgp_brief_request(
|
||||||
db,
|
db,
|
||||||
incident_limit=payload.incident_limit,
|
incident_limit=payload.incident_limit,
|
||||||
anomaly_limit=payload.anomaly_limit,
|
anomaly_limit=payload.anomaly_limit,
|
||||||
@@ -98,4 +218,64 @@ async def analyze_bgp_brief(
|
|||||||
brief_request.thinking = payload.thinking
|
brief_request.thinking = payload.thinking
|
||||||
|
|
||||||
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
||||||
return save_bgp_brief_record(analysis, request_id=request_id)
|
return save_bgp_brief_record(
|
||||||
|
analysis,
|
||||||
|
request_id=request_id,
|
||||||
|
facts=facts,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/alerts/brief", response_model=AlertBriefResponse)
|
||||||
|
async def analyze_alert_brief(
|
||||||
|
payload: AlertBriefRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
provider_client: AIProviderClient = Depends(get_ai_provider_client),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
request_id = request.headers.get("X-Request-ID") or str(uuid4())
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
|
||||||
|
brief_request, facts, context = await build_alert_brief_request(
|
||||||
|
db,
|
||||||
|
alert_limit=payload.alert_limit,
|
||||||
|
)
|
||||||
|
brief_request.preferred_model = payload.preferred_model
|
||||||
|
brief_request.thinking = payload.thinking
|
||||||
|
|
||||||
|
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
||||||
|
return AlertBriefResponse(
|
||||||
|
**analysis.model_dump(),
|
||||||
|
title=brief_request.title,
|
||||||
|
objective=brief_request.objective,
|
||||||
|
facts=facts,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/situational-alerts/brief", response_model=SituationalAlertBriefResponse)
|
||||||
|
async def analyze_situational_alert_brief(
|
||||||
|
payload: SituationalAlertBriefRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
provider_client: AIProviderClient = Depends(get_ai_provider_client),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
request_id = request.headers.get("X-Request-ID") or str(uuid4())
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
|
||||||
|
brief_request, facts, context = await build_situational_alert_brief_request(db)
|
||||||
|
brief_request.preferred_model = payload.preferred_model
|
||||||
|
brief_request.thinking = payload.thinking
|
||||||
|
|
||||||
|
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
||||||
|
return SituationalAlertBriefResponse(
|
||||||
|
**analysis.model_dump(),
|
||||||
|
title=brief_request.title,
|
||||||
|
objective=brief_request.objective,
|
||||||
|
facts=facts,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select, func, case
|
from sqlalchemy import select, func, case
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ from app.db.session import get_db
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.models.alert import Alert, AlertSeverity, AlertStatus
|
from app.models.alert import Alert, AlertSeverity, AlertStatus
|
||||||
|
from app.schemas.alert import AlertResolutionRequest
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -77,7 +78,7 @@ async def acknowledge_alert(
|
|||||||
@router.post("/{alert_id}/resolve")
|
@router.post("/{alert_id}/resolve")
|
||||||
async def resolve_alert(
|
async def resolve_alert(
|
||||||
alert_id: int,
|
alert_id: int,
|
||||||
resolution: str,
|
payload: AlertResolutionRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -85,12 +86,12 @@ async def resolve_alert(
|
|||||||
alert = result.scalar_one_or_none()
|
alert = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not alert:
|
if not alert:
|
||||||
return {"error": "Alert not found"}
|
raise HTTPException(status_code=404, detail="Alert not found")
|
||||||
|
|
||||||
alert.status = AlertStatus.RESOLVED
|
alert.status = AlertStatus.RESOLVED
|
||||||
alert.resolved_by = current_user.id
|
alert.resolved_by = current_user.id
|
||||||
alert.resolved_at = datetime.now(UTC)
|
alert.resolved_at = datetime.now(UTC)
|
||||||
alert.resolution_notes = resolution
|
alert.resolution_notes = payload.resolution
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {"message": "Alert resolved", "alert": alert.to_dict()}
|
return {"message": "Alert resolved", "alert": alert.to_dict()}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select, func, text
|
from sqlalchemy import case, select, func, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ async def init_db():
|
|||||||
import app.models.bgp_observation # noqa: F401
|
import app.models.bgp_observation # noqa: F401
|
||||||
import app.models.collected_data # noqa: F401
|
import app.models.collected_data # noqa: F401
|
||||||
import app.models.system_setting # noqa: F401
|
import app.models.system_setting # noqa: F401
|
||||||
|
import app.models.playground_session # noqa: F401
|
||||||
|
import app.models.playground_message # noqa: F401
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from app.models.bgp_anomaly import BGPAnomaly
|
|||||||
from app.models.bgp_incident import BGPIncident
|
from app.models.bgp_incident import BGPIncident
|
||||||
from app.models.bgp_observation import BGPObservation
|
from app.models.bgp_observation import BGPObservation
|
||||||
from app.models.system_setting import SystemSetting
|
from app.models.system_setting import SystemSetting
|
||||||
|
from app.models.playground_session import PlaygroundSession
|
||||||
|
from app.models.playground_message import PlaygroundMessage
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
|
|||||||
40
backend/app/models/playground_message.py
Normal file
40
backend/app/models/playground_message.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundMessage(Base):
|
||||||
|
__tablename__ = "playground_messages"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
public_id = Column(String(64), unique=True, index=True, nullable=False)
|
||||||
|
session_id = Column(Integer, ForeignKey("playground_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
parent_message_id = Column(Integer, ForeignKey("playground_messages.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
role = Column(String(20), nullable=False)
|
||||||
|
kind = Column(String(20), nullable=False, default="message")
|
||||||
|
status = Column(String(20), nullable=False, default="done")
|
||||||
|
title = Column(String(255), nullable=True)
|
||||||
|
content = Column(Text, nullable=False, default="")
|
||||||
|
thinking_content = Column(Text, nullable=False, default="")
|
||||||
|
meta = Column(JSON, nullable=False, default=list)
|
||||||
|
provider = Column(String(100), nullable=True)
|
||||||
|
model = Column(String(200), nullable=True)
|
||||||
|
request_id = Column(String(100), nullable=True)
|
||||||
|
raw_response = Column(JSON, nullable=False, default=dict)
|
||||||
|
content_blocks = Column(JSON, nullable=False, default=list)
|
||||||
|
text_blocks = Column(JSON, nullable=False, default=list)
|
||||||
|
thinking_blocks = Column(JSON, nullable=False, default=list)
|
||||||
|
sort_order = Column(Integer, nullable=False, default=0, index=True)
|
||||||
|
is_visible = Column(Boolean, nullable=False, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<PlaygroundMessage public_id={self.public_id} role={self.role} status={self.status}>"
|
||||||
27
backend/app/models/playground_session.py
Normal file
27
backend/app/models/playground_session.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundSession(Base):
|
||||||
|
__tablename__ = "playground_sessions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "session_key", name="uq_playground_sessions_user_session_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
session_key = Column(String(100), nullable=False, default="default")
|
||||||
|
title = Column(String(200), nullable=False, default="Playground 会话")
|
||||||
|
state = Column(JSON, nullable=False, default={})
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<PlaygroundSession user_id={self.user_id} session_key={self.session_key}>"
|
||||||
@@ -29,6 +29,17 @@ class BGPBriefRequest(BaseModel):
|
|||||||
thinking: dict[str, Any] | None = None
|
thinking: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlertBriefRequest(BaseModel):
|
||||||
|
alert_limit: int = Field(default=8, ge=1, le=20)
|
||||||
|
preferred_model: str | None = Field(default=None, max_length=200)
|
||||||
|
thinking: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SituationalAlertBriefRequest(BaseModel):
|
||||||
|
preferred_model: str | None = Field(default=None, max_length=200)
|
||||||
|
thinking: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class SituationalAnalysisResponse(BaseModel):
|
class SituationalAnalysisResponse(BaseModel):
|
||||||
provider: str
|
provider: str
|
||||||
model: str
|
model: str
|
||||||
@@ -50,6 +61,22 @@ class BGPBriefRecordSummary(BaseModel):
|
|||||||
|
|
||||||
class BGPBriefRecordResponse(BGPBriefRecordSummary):
|
class BGPBriefRecordResponse(BGPBriefRecordSummary):
|
||||||
content_markdown: str
|
content_markdown: str
|
||||||
|
facts: list[str] = Field(default_factory=list)
|
||||||
|
context: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AlertBriefResponse(SituationalAnalysisResponse):
|
||||||
|
title: str
|
||||||
|
objective: str
|
||||||
|
facts: list[str] = Field(default_factory=list)
|
||||||
|
context: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class SituationalAlertBriefResponse(SituationalAnalysisResponse):
|
||||||
|
title: str
|
||||||
|
objective: str
|
||||||
|
facts: list[str] = Field(default_factory=list)
|
||||||
|
context: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class AIProviderStatusResponse(BaseModel):
|
class AIProviderStatusResponse(BaseModel):
|
||||||
@@ -59,3 +86,90 @@ class AIProviderStatusResponse(BaseModel):
|
|||||||
configured: bool
|
configured: bool
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
base_url: str | None = None
|
base_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundSessionState(BaseModel):
|
||||||
|
messages: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
selectedPresetKey: str = Field(default="bgp-brief", max_length=100)
|
||||||
|
title: str = Field(default="", max_length=200)
|
||||||
|
objective: str = Field(default="", max_length=1000)
|
||||||
|
constraints: str = Field(default="")
|
||||||
|
inputValue: str = Field(default="")
|
||||||
|
analysis: dict[str, Any] | None = None
|
||||||
|
latestAnalysisMessageId: str | None = Field(default=None, max_length=200)
|
||||||
|
analysisMeta: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
helpExpanded: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundSessionUpsertRequest(BaseModel):
|
||||||
|
session_key: str = Field(default="default", min_length=1, max_length=100)
|
||||||
|
title: str | None = Field(default=None, max_length=200)
|
||||||
|
state: PlaygroundSessionState
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundMessageRecord(BaseModel):
|
||||||
|
id: str
|
||||||
|
role: str
|
||||||
|
kind: str = "message"
|
||||||
|
status: str = "done"
|
||||||
|
title: str | None = None
|
||||||
|
content: str = ""
|
||||||
|
thinking_content: str = ""
|
||||||
|
meta: list[str] = Field(default_factory=list)
|
||||||
|
markdown: bool = True
|
||||||
|
provider: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
request_id: str | None = None
|
||||||
|
raw_response: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
content_blocks: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
text_blocks: list[str] = Field(default_factory=list)
|
||||||
|
thinking_blocks: list[str] = Field(default_factory=list)
|
||||||
|
parent_message_id: str | None = None
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundSessionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
session_key: str
|
||||||
|
title: str
|
||||||
|
state: PlaygroundSessionState
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundThreadResponse(BaseModel):
|
||||||
|
session: PlaygroundSessionResponse
|
||||||
|
messages: list[PlaygroundMessageRecord] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundMessageCreateRequest(BaseModel):
|
||||||
|
session_key: str = Field(default="default", min_length=1, max_length=100)
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
objective: str = Field(..., min_length=1, max_length=1000)
|
||||||
|
constraints: str = Field(default="")
|
||||||
|
input: str = Field(..., min_length=1)
|
||||||
|
selected_preset_key: str = Field(default="bgp-brief", max_length=100)
|
||||||
|
help_expanded: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundMessageActionResponse(BaseModel):
|
||||||
|
session: PlaygroundSessionResponse
|
||||||
|
messages: list[PlaygroundMessageRecord] = Field(default_factory=list)
|
||||||
|
active_message_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundMessageStopRequest(BaseModel):
|
||||||
|
session_key: str = Field(default="default", min_length=1, max_length=100)
|
||||||
|
message_id: str = Field(..., min_length=1, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundMessageResendRequest(BaseModel):
|
||||||
|
session_key: str = Field(default="default", min_length=1, max_length=100)
|
||||||
|
user_message_id: str = Field(..., min_length=1, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundMessageEditRequest(BaseModel):
|
||||||
|
session_key: str = Field(default="default", min_length=1, max_length=100)
|
||||||
|
user_message_id: str = Field(..., min_length=1, max_length=64)
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
|||||||
5
backend/app/schemas/alert.py
Normal file
5
backend/app/schemas/alert.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AlertResolutionRequest(BaseModel):
|
||||||
|
resolution: str = Field(..., min_length=1, max_length=1000)
|
||||||
103
backend/app/services/alert_ai_brief.py
Normal file
103
backend/app/services/alert_ai_brief.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.alert import Alert, AlertSeverity, AlertStatus
|
||||||
|
from app.schemas.ai import AlertBriefRequest, SituationalAnalysisRequest
|
||||||
|
|
||||||
|
|
||||||
|
def _format_counter(counter: Counter[str], empty_text: str = "无") -> str:
|
||||||
|
if not counter:
|
||||||
|
return empty_text
|
||||||
|
return ",".join(f"{key} {value}" for key, value in counter.items())
|
||||||
|
|
||||||
|
|
||||||
|
async def build_alert_brief_request(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
alert_limit: int = 8,
|
||||||
|
) -> tuple[SituationalAnalysisRequest, list[str], dict[str, Any]]:
|
||||||
|
recent_alerts_result = await db.execute(
|
||||||
|
select(Alert)
|
||||||
|
.order_by(Alert.created_at.desc(), Alert.id.desc())
|
||||||
|
.limit(max(alert_limit, 1))
|
||||||
|
)
|
||||||
|
total_result = await db.execute(select(func.count(Alert.id)))
|
||||||
|
active_result = await db.execute(select(func.count(Alert.id)).where(Alert.status == AlertStatus.ACTIVE))
|
||||||
|
acknowledged_result = await db.execute(
|
||||||
|
select(func.count(Alert.id)).where(Alert.status == AlertStatus.ACKNOWLEDGED)
|
||||||
|
)
|
||||||
|
resolved_result = await db.execute(select(func.count(Alert.id)).where(Alert.status == AlertStatus.RESOLVED))
|
||||||
|
|
||||||
|
recent_alerts = recent_alerts_result.scalars().all()
|
||||||
|
total_alerts = total_result.scalar() or 0
|
||||||
|
active_alerts = active_result.scalar() or 0
|
||||||
|
acknowledged_alerts = acknowledged_result.scalar() or 0
|
||||||
|
resolved_alerts = resolved_result.scalar() or 0
|
||||||
|
|
||||||
|
severity_counts = Counter((item.severity.value if item.severity else "unknown") for item in recent_alerts)
|
||||||
|
status_counts = Counter((item.status.value if item.status else "unknown") for item in recent_alerts)
|
||||||
|
datasource_counts = Counter((item.datasource_name or "未命名数据源") for item in recent_alerts)
|
||||||
|
active_datasource_counts = Counter(
|
||||||
|
(item.datasource_name or "未命名数据源")
|
||||||
|
for item in recent_alerts
|
||||||
|
if item.status == AlertStatus.ACTIVE
|
||||||
|
)
|
||||||
|
|
||||||
|
facts = [
|
||||||
|
f"告警总量 {total_alerts} 条,其中 active {active_alerts} 条、acknowledged {acknowledged_alerts} 条、resolved {resolved_alerts} 条。",
|
||||||
|
f"最近告警严重度分布:{_format_counter(severity_counts)}。",
|
||||||
|
f"最近告警状态分布:{_format_counter(status_counts)}。",
|
||||||
|
f"最近告警数据源分布:{_format_counter(Counter(dict(datasource_counts.most_common(6))))}。",
|
||||||
|
]
|
||||||
|
|
||||||
|
if active_datasource_counts:
|
||||||
|
facts.append(
|
||||||
|
"当前待处理告警主要集中在:"
|
||||||
|
+ _format_counter(Counter(dict(active_datasource_counts.most_common(5))))
|
||||||
|
+ "。"
|
||||||
|
)
|
||||||
|
|
||||||
|
if recent_alerts:
|
||||||
|
facts.append(
|
||||||
|
"最近告警摘录:"
|
||||||
|
+ ";".join(
|
||||||
|
[
|
||||||
|
f"{item.datasource_name or '未命名数据源'} / {item.severity.value if item.severity else '-'} / {item.status.value if item.status else '-'} / {item.message or '-'}"
|
||||||
|
for item in recent_alerts[:6]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"source": "alerts",
|
||||||
|
"total_alerts": total_alerts,
|
||||||
|
"active_alerts": active_alerts,
|
||||||
|
"acknowledged_alerts": acknowledged_alerts,
|
||||||
|
"resolved_alerts": resolved_alerts,
|
||||||
|
"severity_distribution": dict(severity_counts),
|
||||||
|
"status_distribution": dict(status_counts),
|
||||||
|
"top_datasources": dict(datasource_counts.most_common(6)),
|
||||||
|
"top_active_datasources": dict(active_datasource_counts.most_common(5)),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
SituationalAnalysisRequest(
|
||||||
|
title="告警态势 AI 简报",
|
||||||
|
objective="基于当前告警总量、严重度、状态、数据源分布与最近告警摘录,生成一份面向值班人员的简明告警态势简报,突出待处理风险、告警集中点和优先动作。",
|
||||||
|
observations=facts,
|
||||||
|
constraints=[
|
||||||
|
"明确区分事实、推断与建议。",
|
||||||
|
"优先指出仍处于 active 状态且高严重度的告警簇。",
|
||||||
|
"不要把 acknowledged 或 resolved 告警误判成当前仍在扩大。",
|
||||||
|
"如果证据不足,请明确指出缺失的上下文。",
|
||||||
|
],
|
||||||
|
context=context,
|
||||||
|
),
|
||||||
|
facts,
|
||||||
|
context,
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -11,6 +12,7 @@ from app.models.bgp_incident import BGPIncident
|
|||||||
from app.models.bgp_observation import BGPObservation
|
from app.models.bgp_observation import BGPObservation
|
||||||
from app.schemas.ai import SituationalAnalysisRequest
|
from app.schemas.ai import SituationalAnalysisRequest
|
||||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||||
|
from app.services.bgp_enrichment import lookup_prefix_geography
|
||||||
|
|
||||||
|
|
||||||
def _format_counter(counter: dict[str, int], empty_text: str = "无") -> str:
|
def _format_counter(counter: dict[str, int], empty_text: str = "无") -> str:
|
||||||
@@ -30,13 +32,59 @@ def _severity_rank(value: str | None) -> int:
|
|||||||
return order.get((value or "").lower(), 99)
|
return order.get((value or "").lower(), 99)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_geo_key(country: str | None, city: str | None) -> str:
|
||||||
|
if city and country:
|
||||||
|
return f"{city}, {country}"
|
||||||
|
return city or country or "未知区域"
|
||||||
|
|
||||||
|
|
||||||
|
def _top_counter_items(counter: Counter[str], limit: int = 5) -> dict[str, int]:
|
||||||
|
return {name: count for name, count in counter.most_common(limit) if name}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_incident_regions(incidents: list[BGPIncident]) -> Counter[str]:
|
||||||
|
counter: Counter[str] = Counter()
|
||||||
|
for item in incidents:
|
||||||
|
for region in item.affected_regions or []:
|
||||||
|
if not isinstance(region, dict):
|
||||||
|
continue
|
||||||
|
counter[_normalize_geo_key(region.get("country"), region.get("city"))] += 1
|
||||||
|
return counter
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_collector_regions(collectors: list[dict[str, Any]]) -> Counter[str]:
|
||||||
|
counter: Counter[str] = Counter()
|
||||||
|
for item in collectors:
|
||||||
|
counter[_normalize_geo_key(item.get("country"), item.get("city"))] += int(item.get("recent_24h_observation_count") or 0)
|
||||||
|
return counter
|
||||||
|
|
||||||
|
|
||||||
|
def _format_geo_evidence(prefix_geographies: dict[str, dict[str, Any]], limit: int = 6) -> str:
|
||||||
|
if not prefix_geographies:
|
||||||
|
return "没有命中 prefix geography 证据。"
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for prefix, item in list(prefix_geographies.items())[:limit]:
|
||||||
|
region = _normalize_geo_key(item.get("country"), item.get("city"))
|
||||||
|
source = item.get("source") or item.get("geography_mode") or "unknown"
|
||||||
|
as_hint = item.get("asn")
|
||||||
|
as_name = item.get("as_name")
|
||||||
|
as_text = ""
|
||||||
|
if as_hint:
|
||||||
|
as_text = f" / ASN AS{as_hint}"
|
||||||
|
if as_name:
|
||||||
|
as_text += f" ({as_name})"
|
||||||
|
rows.append(f"{prefix} -> {region} / 来源 {source}{as_text}")
|
||||||
|
return ";".join(rows)
|
||||||
|
|
||||||
|
|
||||||
async def build_bgp_brief_request(
|
async def build_bgp_brief_request(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
incident_limit: int = 5,
|
incident_limit: int = 5,
|
||||||
anomaly_limit: int = 6,
|
anomaly_limit: int = 6,
|
||||||
collector_limit: int = 5,
|
collector_limit: int = 5,
|
||||||
) -> SituationalAnalysisRequest:
|
) -> tuple[SituationalAnalysisRequest, list[str], dict[str, int | str | dict[str, int]]]:
|
||||||
incidents_result = await db.execute(
|
incidents_result = await db.execute(
|
||||||
select(BGPIncident)
|
select(BGPIncident)
|
||||||
.order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc())
|
.order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc())
|
||||||
@@ -68,6 +116,7 @@ async def build_bgp_brief_request(
|
|||||||
incident_type_counts = Counter((item.incident_type or "unknown") for item in incidents)
|
incident_type_counts = Counter((item.incident_type or "unknown") for item in incidents)
|
||||||
anomaly_type_counts = Counter((item.anomaly_type or "unknown") for item in anomalies)
|
anomaly_type_counts = Counter((item.anomaly_type or "unknown") for item in anomalies)
|
||||||
event_type_counts = Counter((item.event_type or "unknown") for item in observations)
|
event_type_counts = Counter((item.event_type or "unknown") for item in observations)
|
||||||
|
incident_region_counts = _collect_incident_regions(incidents)
|
||||||
|
|
||||||
top_collectors = sorted(
|
top_collectors = sorted(
|
||||||
active_collectors,
|
active_collectors,
|
||||||
@@ -77,6 +126,29 @@ async def build_bgp_brief_request(
|
|||||||
str(item["collector"]),
|
str(item["collector"]),
|
||||||
),
|
),
|
||||||
)[: max(collector_limit, 1)]
|
)[: max(collector_limit, 1)]
|
||||||
|
collector_region_counts = _collect_collector_regions(top_collectors)
|
||||||
|
|
||||||
|
prefix_candidates = sorted(
|
||||||
|
{
|
||||||
|
prefix
|
||||||
|
for item in incidents
|
||||||
|
for prefix in (item.affected_prefixes or [])
|
||||||
|
if prefix
|
||||||
|
}
|
||||||
|
| {item.prefix for item in anomalies if item.prefix}
|
||||||
|
)
|
||||||
|
prefix_geographies = await lookup_prefix_geography(db, prefix_candidates) if prefix_candidates else {}
|
||||||
|
geography_region_counts = Counter(
|
||||||
|
_normalize_geo_key(item.get("country"), item.get("city"))
|
||||||
|
for item in prefix_geographies.values()
|
||||||
|
if item.get("country") or item.get("city")
|
||||||
|
)
|
||||||
|
hotspot_region_counts = geography_region_counts + incident_region_counts
|
||||||
|
collector_bias_regions = [
|
||||||
|
region
|
||||||
|
for region, count in collector_region_counts.most_common(3)
|
||||||
|
if count > hotspot_region_counts.get(region, 0)
|
||||||
|
]
|
||||||
|
|
||||||
observations_lines: list[str] = [
|
observations_lines: list[str] = [
|
||||||
f"当前共有 {total_incidents} 起 BGP incidents、{total_anomalies} 条 anomalies、{total_observations} 条原始观测事件。",
|
f"当前共有 {total_incidents} 起 BGP incidents、{total_anomalies} 条 anomalies、{total_observations} 条原始观测事件。",
|
||||||
@@ -88,6 +160,27 @@ async def build_bgp_brief_request(
|
|||||||
f"观测事件类型分布:{_format_counter(dict(event_type_counts.most_common(6)))}。",
|
f"观测事件类型分布:{_format_counter(dict(event_type_counts.most_common(6)))}。",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if hotspot_region_counts:
|
||||||
|
observations_lines.append(
|
||||||
|
"区域热点事实层:"
|
||||||
|
+ _format_counter(_top_counter_items(hotspot_region_counts, limit=5), empty_text="无明显区域聚集")
|
||||||
|
+ "。"
|
||||||
|
)
|
||||||
|
|
||||||
|
if prefix_geographies:
|
||||||
|
observations_lines.append("Prefix geography 证据:" + _format_geo_evidence(prefix_geographies))
|
||||||
|
|
||||||
|
if collector_bias_regions:
|
||||||
|
observations_lines.append(
|
||||||
|
"观测偏差提示:重点观测站最近 24h 活跃度更集中在 "
|
||||||
|
+ "、".join(collector_bias_regions)
|
||||||
|
+ ",这些区域的事件升温结论需要结合 prefix geography 与 affected regions 交叉验证。"
|
||||||
|
)
|
||||||
|
elif top_collectors:
|
||||||
|
observations_lines.append(
|
||||||
|
"观测偏差提示:当前未发现明显高于区域热点事实层的单一观测站集中区域,但仍需区分 collector coverage 与真实区域风险。"
|
||||||
|
)
|
||||||
|
|
||||||
if incidents:
|
if incidents:
|
||||||
observations_lines.append(
|
observations_lines.append(
|
||||||
"最近 incident 摘要:" + ";".join(
|
"最近 incident 摘要:" + ";".join(
|
||||||
@@ -124,24 +217,43 @@ async def build_bgp_brief_request(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"source": "bgp-overview",
|
||||||
|
"incident_total": total_incidents,
|
||||||
|
"anomaly_total": total_anomalies,
|
||||||
|
"observation_total": total_observations,
|
||||||
|
"active_collectors": len(active_collectors),
|
||||||
|
"top_incident_types": dict(incident_type_counts.most_common(5)),
|
||||||
|
"top_anomaly_types": dict(anomaly_type_counts.most_common(6)),
|
||||||
|
"top_event_types": dict(event_type_counts.most_common(6)),
|
||||||
|
"region_hotspots": _top_counter_items(hotspot_region_counts, limit=6),
|
||||||
|
"incident_regions": _top_counter_items(incident_region_counts, limit=6),
|
||||||
|
"collector_bias_regions": collector_bias_regions,
|
||||||
|
"prefix_geography_sources": dict(
|
||||||
|
Counter(str(item.get("source") or "unknown") for item in prefix_geographies.values()).most_common(5)
|
||||||
|
),
|
||||||
|
"prefix_geography_sample": {
|
||||||
|
prefix: {
|
||||||
|
"country": item.get("country"),
|
||||||
|
"city": item.get("city"),
|
||||||
|
"source": item.get("source"),
|
||||||
|
"asn": item.get("asn"),
|
||||||
|
"as_name": item.get("as_name"),
|
||||||
|
}
|
||||||
|
for prefix, item in list(prefix_geographies.items())[:8]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return SituationalAnalysisRequest(
|
return SituationalAnalysisRequest(
|
||||||
title="BGP 态势 AI 简报",
|
title="BGP 态势 AI 简报",
|
||||||
objective="基于当前 BGP incidents、anomalies、原始观测事件与观测站覆盖情况,生成一份面向操作员的简明态势简报,突出当前风险、证据和优先动作。",
|
objective="基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。",
|
||||||
observations=observations_lines,
|
observations=observations_lines,
|
||||||
constraints=[
|
constraints=[
|
||||||
"明确区分事实、推断与建议。",
|
"明确区分事实、推断与建议。",
|
||||||
"优先指出需要立即关注的高严重度 incident 或异常模式。",
|
"优先指出需要立即关注的高严重度 incident 或异常模式。",
|
||||||
|
"需要单独指出哪些区域结论来自 prefix geography / affected regions,哪些可能受 collector coverage 偏差影响。",
|
||||||
"结论应服务值班排障,不要写成泛泛的模型演示文案。",
|
"结论应服务值班排障,不要写成泛泛的模型演示文案。",
|
||||||
"如果证据不足,要明确指出缺失数据。",
|
"如果证据不足,要明确指出缺失数据。",
|
||||||
],
|
],
|
||||||
context={
|
context=context,
|
||||||
"source": "bgp-overview",
|
), observations_lines, context
|
||||||
"incident_total": total_incidents,
|
|
||||||
"anomaly_total": total_anomalies,
|
|
||||||
"observation_total": total_observations,
|
|
||||||
"active_collectors": len(active_collectors),
|
|
||||||
"top_incident_types": dict(incident_type_counts.most_common(5)),
|
|
||||||
"top_anomaly_types": dict(anomaly_type_counts.most_common(6)),
|
|
||||||
"top_event_types": dict(event_type_counts.most_common(6)),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from app.core.config import ROOT_DIR
|
from app.core.config import ROOT_DIR
|
||||||
@@ -25,6 +26,8 @@ class _StoredBrief:
|
|||||||
request_id: str | None
|
request_id: str | None
|
||||||
generated_at: str
|
generated_at: str
|
||||||
content_markdown: str
|
content_markdown: str
|
||||||
|
facts: list[str]
|
||||||
|
context: dict[str, Any]
|
||||||
path: Path
|
path: Path
|
||||||
|
|
||||||
|
|
||||||
@@ -33,7 +36,7 @@ def _ensure_storage_dir() -> Path:
|
|||||||
return _BRIEF_STORAGE_DIR
|
return _BRIEF_STORAGE_DIR
|
||||||
|
|
||||||
|
|
||||||
def _build_metadata_line(metadata: dict[str, str | None]) -> str:
|
def _build_metadata_line(metadata: dict[str, Any]) -> str:
|
||||||
return f"{_METADATA_PREFIX}{json.dumps(metadata, ensure_ascii=False)}{_METADATA_SUFFIX}"
|
return f"{_METADATA_PREFIX}{json.dumps(metadata, ensure_ascii=False)}{_METADATA_SUFFIX}"
|
||||||
|
|
||||||
|
|
||||||
@@ -62,6 +65,8 @@ def _parse_brief_file(path: Path) -> _StoredBrief | None:
|
|||||||
request_id=metadata.get("request_id"),
|
request_id=metadata.get("request_id"),
|
||||||
generated_at=str(metadata.get("generated_at") or datetime.fromtimestamp(path.stat().st_mtime, UTC).isoformat()),
|
generated_at=str(metadata.get("generated_at") or datetime.fromtimestamp(path.stat().st_mtime, UTC).isoformat()),
|
||||||
content_markdown=remainder.lstrip("\n"),
|
content_markdown=remainder.lstrip("\n"),
|
||||||
|
facts=list(metadata.get("facts") or []),
|
||||||
|
context=dict(metadata.get("context") or {}),
|
||||||
path=path,
|
path=path,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,6 +109,8 @@ def get_bgp_brief_record(brief_id: str) -> BGPBriefRecordResponse | None:
|
|||||||
request_id=parsed.request_id,
|
request_id=parsed.request_id,
|
||||||
generated_at=parsed.generated_at,
|
generated_at=parsed.generated_at,
|
||||||
content_markdown=parsed.content_markdown,
|
content_markdown=parsed.content_markdown,
|
||||||
|
facts=parsed.facts,
|
||||||
|
context=parsed.context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -118,6 +125,8 @@ def save_bgp_brief_record(
|
|||||||
analysis: SituationalAnalysisResponse,
|
analysis: SituationalAnalysisResponse,
|
||||||
*,
|
*,
|
||||||
request_id: str | None,
|
request_id: str | None,
|
||||||
|
facts: list[str] | None = None,
|
||||||
|
context: dict[str, Any] | None = None,
|
||||||
generated_at: datetime | None = None,
|
generated_at: datetime | None = None,
|
||||||
) -> BGPBriefRecordResponse:
|
) -> BGPBriefRecordResponse:
|
||||||
created_at = generated_at or datetime.now(UTC)
|
created_at = generated_at or datetime.now(UTC)
|
||||||
@@ -131,6 +140,8 @@ def save_bgp_brief_record(
|
|||||||
"model": analysis.model,
|
"model": analysis.model,
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"generated_at": created_at.isoformat(),
|
"generated_at": created_at.isoformat(),
|
||||||
|
"facts": facts or [],
|
||||||
|
"context": context or {},
|
||||||
}
|
}
|
||||||
|
|
||||||
markdown_text = f"{_build_metadata_line(metadata)}\n\n{analysis.content.rstrip()}\n"
|
markdown_text = f"{_build_metadata_line(metadata)}\n\n{analysis.content.rstrip()}\n"
|
||||||
@@ -144,4 +155,6 @@ def save_bgp_brief_record(
|
|||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
generated_at=created_at.isoformat(),
|
generated_at=created_at.isoformat(),
|
||||||
content_markdown=analysis.content,
|
content_markdown=analysis.content,
|
||||||
|
facts=facts or [],
|
||||||
|
context=context or {},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -231,6 +231,13 @@ async def _lookup_prefix_geography(
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def lookup_prefix_geography(
|
||||||
|
db: AsyncSession,
|
||||||
|
prefix_values: list[str],
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
return await _lookup_prefix_geography(db, prefix_values)
|
||||||
|
|
||||||
|
|
||||||
async def enrich_bgp_events_for_batch(
|
async def enrich_bgp_events_for_batch(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|||||||
678
backend/app/services/playground_chat_service.py
Normal file
678
backend/app/services/playground_chat_service.py
Normal file
@@ -0,0 +1,678 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from time import perf_counter
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.session import async_session_factory
|
||||||
|
from app.models.playground_message import PlaygroundMessage
|
||||||
|
from app.models.playground_session import PlaygroundSession
|
||||||
|
from app.schemas.ai import (
|
||||||
|
PlaygroundMessageEditRequest,
|
||||||
|
PlaygroundMessageActionResponse,
|
||||||
|
PlaygroundMessageCreateRequest,
|
||||||
|
PlaygroundMessageRecord,
|
||||||
|
PlaygroundMessageResendRequest,
|
||||||
|
PlaygroundMessageStopRequest,
|
||||||
|
PlaygroundSessionResponse,
|
||||||
|
PlaygroundSessionState,
|
||||||
|
PlaygroundSessionUpsertRequest,
|
||||||
|
PlaygroundThreadResponse,
|
||||||
|
SituationalAnalysisRequest,
|
||||||
|
)
|
||||||
|
from app.services.ai_client import AIProviderClient
|
||||||
|
from app.services.playground_session_store import _to_response as session_to_response
|
||||||
|
from app.services.playground_session_store import upsert_playground_session
|
||||||
|
|
||||||
|
STREAM_CHUNK_SIZE = 24
|
||||||
|
STREAM_INTERVAL_SECONDS = 0.08
|
||||||
|
THINKING_PREVIEW_SECONDS = 2.6
|
||||||
|
|
||||||
|
|
||||||
|
class _ActiveRun:
|
||||||
|
def __init__(self, task: asyncio.Task[None]) -> None:
|
||||||
|
self.task = task
|
||||||
|
self.stop_requested = asyncio.Event()
|
||||||
|
|
||||||
|
|
||||||
|
_ACTIVE_RUNS: dict[str, _ActiveRun] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _message_to_record(message: PlaygroundMessage, parent_public_id: str | None = None) -> PlaygroundMessageRecord:
|
||||||
|
return PlaygroundMessageRecord(
|
||||||
|
id=message.public_id,
|
||||||
|
role=message.role,
|
||||||
|
kind=message.kind,
|
||||||
|
status=message.status,
|
||||||
|
title=message.title,
|
||||||
|
content=message.content or "",
|
||||||
|
thinking_content=message.thinking_content or "",
|
||||||
|
meta=list(message.meta or []),
|
||||||
|
markdown=message.role != "system",
|
||||||
|
provider=message.provider,
|
||||||
|
model=message.model,
|
||||||
|
request_id=message.request_id,
|
||||||
|
raw_response=dict(message.raw_response or {}),
|
||||||
|
content_blocks=list(message.content_blocks or []),
|
||||||
|
text_blocks=list(message.text_blocks or []),
|
||||||
|
thinking_blocks=list(message.thinking_blocks or []),
|
||||||
|
parent_message_id=parent_public_id,
|
||||||
|
created_at=message.created_at.isoformat(),
|
||||||
|
updated_at=message.updated_at.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_session(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
session_key: str,
|
||||||
|
title: str,
|
||||||
|
state: PlaygroundSessionState | None = None,
|
||||||
|
) -> PlaygroundSession:
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlaygroundSession).where(
|
||||||
|
PlaygroundSession.user_id == user_id,
|
||||||
|
PlaygroundSession.session_key == session_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
if session is not None:
|
||||||
|
if title:
|
||||||
|
session.title = title[:200]
|
||||||
|
if state is not None:
|
||||||
|
session.state = state.model_dump(mode="json")
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
payload = PlaygroundSessionUpsertRequest(
|
||||||
|
session_key=session_key,
|
||||||
|
title=title[:200],
|
||||||
|
state=state or PlaygroundSessionState(title=title[:200]),
|
||||||
|
)
|
||||||
|
await upsert_playground_session(db, user_id=user_id, payload=payload)
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlaygroundSession).where(
|
||||||
|
PlaygroundSession.user_id == user_id,
|
||||||
|
PlaygroundSession.session_key == session_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_visible_messages(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
session_id: int,
|
||||||
|
) -> list[PlaygroundMessage]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlaygroundMessage)
|
||||||
|
.where(
|
||||||
|
PlaygroundMessage.session_id == session_id,
|
||||||
|
PlaygroundMessage.is_visible.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(PlaygroundMessage.sort_order.asc(), PlaygroundMessage.id.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_thread_response(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
session: PlaygroundSession,
|
||||||
|
) -> PlaygroundThreadResponse:
|
||||||
|
messages = await _list_visible_messages(db, session_id=session.id)
|
||||||
|
id_map = {item.id: item.public_id for item in messages}
|
||||||
|
return PlaygroundThreadResponse(
|
||||||
|
session=session_to_response(session),
|
||||||
|
messages=[_message_to_record(item, id_map.get(item.parent_message_id)) for item in messages],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_thread(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
session_key: str,
|
||||||
|
) -> PlaygroundThreadResponse | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlaygroundSession).where(
|
||||||
|
PlaygroundSession.user_id == user_id,
|
||||||
|
PlaygroundSession.session_key == session_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
if session is None:
|
||||||
|
return None
|
||||||
|
return await _build_thread_response(db, session=session)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_constraints(raw_constraints: str) -> list[str]:
|
||||||
|
return [item.strip() for item in raw_constraints.split("\n") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
async def _next_sort_order(db: AsyncSession, session_id: int) -> int:
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.max(PlaygroundMessage.sort_order)).where(PlaygroundMessage.session_id == session_id)
|
||||||
|
)
|
||||||
|
current = result.scalar_one_or_none()
|
||||||
|
return int(current or 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_session_state(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
session: PlaygroundSession,
|
||||||
|
payload: PlaygroundMessageCreateRequest,
|
||||||
|
) -> PlaygroundSession:
|
||||||
|
session.state = PlaygroundSessionState(
|
||||||
|
messages=[],
|
||||||
|
selectedPresetKey=payload.selected_preset_key,
|
||||||
|
title=payload.title,
|
||||||
|
objective=payload.objective,
|
||||||
|
constraints=payload.constraints,
|
||||||
|
inputValue="",
|
||||||
|
analysis=None,
|
||||||
|
latestAnalysisMessageId=None,
|
||||||
|
analysisMeta={},
|
||||||
|
helpExpanded=payload.help_expanded,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
session.title = payload.title[:200]
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
async def create_turn(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
payload: PlaygroundMessageCreateRequest,
|
||||||
|
provider_client: AIProviderClient,
|
||||||
|
) -> PlaygroundMessageActionResponse:
|
||||||
|
session = await _ensure_session(
|
||||||
|
db,
|
||||||
|
user_id=user_id,
|
||||||
|
session_key=payload.session_key,
|
||||||
|
title=payload.title,
|
||||||
|
state=PlaygroundSessionState(
|
||||||
|
selectedPresetKey=payload.selected_preset_key,
|
||||||
|
title=payload.title,
|
||||||
|
objective=payload.objective,
|
||||||
|
constraints=payload.constraints,
|
||||||
|
inputValue="",
|
||||||
|
helpExpanded=payload.help_expanded,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session = await _set_session_state(db, session=session, payload=payload)
|
||||||
|
base_order = await _next_sort_order(db, session.id)
|
||||||
|
|
||||||
|
user_message = PlaygroundMessage(
|
||||||
|
public_id=uuid4().hex,
|
||||||
|
session_id=session.id,
|
||||||
|
user_id=user_id,
|
||||||
|
role="user",
|
||||||
|
kind="message",
|
||||||
|
status="done",
|
||||||
|
title=payload.selected_preset_key,
|
||||||
|
content=payload.input,
|
||||||
|
meta=[payload.title],
|
||||||
|
sort_order=base_order + 10,
|
||||||
|
)
|
||||||
|
assistant_message = PlaygroundMessage(
|
||||||
|
public_id=uuid4().hex,
|
||||||
|
session_id=session.id,
|
||||||
|
user_id=user_id,
|
||||||
|
parent_message_id=None,
|
||||||
|
role="assistant",
|
||||||
|
kind="thinking",
|
||||||
|
status="pending",
|
||||||
|
title="AI 回应",
|
||||||
|
content="",
|
||||||
|
thinking_content="",
|
||||||
|
meta=[],
|
||||||
|
sort_order=base_order + 20,
|
||||||
|
)
|
||||||
|
db.add(user_message)
|
||||||
|
await db.flush()
|
||||||
|
assistant_message.parent_message_id = user_message.id
|
||||||
|
db.add(assistant_message)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(user_message)
|
||||||
|
await db.refresh(assistant_message)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(session)
|
||||||
|
await db.refresh(user_message)
|
||||||
|
await db.refresh(assistant_message)
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
_run_assistant_message(
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session.id,
|
||||||
|
session_key=payload.session_key,
|
||||||
|
user_message_id=user_message.id,
|
||||||
|
assistant_message_id=assistant_message.id,
|
||||||
|
payload=payload,
|
||||||
|
provider_client=provider_client,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ACTIVE_RUNS[assistant_message.public_id] = _ActiveRun(task)
|
||||||
|
|
||||||
|
thread = await _build_thread_response(db, session=session)
|
||||||
|
return PlaygroundMessageActionResponse(
|
||||||
|
session=thread.session,
|
||||||
|
messages=thread.messages,
|
||||||
|
active_message_id=assistant_message.public_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_assistant_retry_turn(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
session: PlaygroundSession,
|
||||||
|
user_message: PlaygroundMessage,
|
||||||
|
payload: PlaygroundMessageCreateRequest,
|
||||||
|
provider_client: AIProviderClient,
|
||||||
|
) -> PlaygroundMessageActionResponse:
|
||||||
|
base_order = await _next_sort_order(db, session.id)
|
||||||
|
assistant_message = PlaygroundMessage(
|
||||||
|
public_id=uuid4().hex,
|
||||||
|
session_id=session.id,
|
||||||
|
user_id=user_id,
|
||||||
|
parent_message_id=user_message.id,
|
||||||
|
role="assistant",
|
||||||
|
kind="thinking",
|
||||||
|
status="pending",
|
||||||
|
title="AI 回应",
|
||||||
|
content="",
|
||||||
|
thinking_content="",
|
||||||
|
meta=[],
|
||||||
|
sort_order=base_order + 10,
|
||||||
|
)
|
||||||
|
db.add(assistant_message)
|
||||||
|
await db.flush()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(session)
|
||||||
|
await db.refresh(assistant_message)
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
_run_assistant_message(
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session.id,
|
||||||
|
session_key=payload.session_key,
|
||||||
|
user_message_id=user_message.id,
|
||||||
|
assistant_message_id=assistant_message.id,
|
||||||
|
payload=payload,
|
||||||
|
provider_client=provider_client,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ACTIVE_RUNS[assistant_message.public_id] = _ActiveRun(task)
|
||||||
|
|
||||||
|
thread = await _build_thread_response(db, session=session)
|
||||||
|
return PlaygroundMessageActionResponse(
|
||||||
|
session=thread.session,
|
||||||
|
messages=thread.messages,
|
||||||
|
active_message_id=assistant_message.public_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_message(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
payload: PlaygroundMessageStopRequest,
|
||||||
|
) -> PlaygroundMessageActionResponse:
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlaygroundSession).where(
|
||||||
|
PlaygroundSession.user_id == user_id,
|
||||||
|
PlaygroundSession.session_key == payload.session_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlaygroundMessage).where(
|
||||||
|
PlaygroundMessage.user_id == user_id,
|
||||||
|
PlaygroundMessage.public_id == payload.message_id,
|
||||||
|
PlaygroundMessage.is_visible.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
message = result.scalar_one_or_none()
|
||||||
|
if message is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground message not found")
|
||||||
|
|
||||||
|
if message.status not in {"pending", "thinking", "answering"}:
|
||||||
|
thread = await _build_thread_response(db, session=session)
|
||||||
|
return PlaygroundMessageActionResponse(
|
||||||
|
session=thread.session,
|
||||||
|
messages=thread.messages,
|
||||||
|
active_message_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
active_run = _ACTIVE_RUNS.get(message.public_id)
|
||||||
|
if active_run is not None:
|
||||||
|
active_run.stop_requested.set()
|
||||||
|
active_run.task.cancel()
|
||||||
|
|
||||||
|
message.status = "stopped"
|
||||||
|
if "已手动停止生成" not in (message.meta or []):
|
||||||
|
message.meta = [*(message.meta or []), "已手动停止生成"]
|
||||||
|
await db.flush()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(message)
|
||||||
|
|
||||||
|
thread = await _build_thread_response(db, session=session)
|
||||||
|
return PlaygroundMessageActionResponse(
|
||||||
|
session=thread.session,
|
||||||
|
messages=thread.messages,
|
||||||
|
active_message_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def resend_turn(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
payload: PlaygroundMessageResendRequest,
|
||||||
|
provider_client: AIProviderClient,
|
||||||
|
) -> PlaygroundMessageActionResponse:
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlaygroundSession).where(
|
||||||
|
PlaygroundSession.user_id == user_id,
|
||||||
|
PlaygroundSession.session_key == payload.session_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlaygroundMessage).where(
|
||||||
|
PlaygroundMessage.user_id == user_id,
|
||||||
|
PlaygroundMessage.public_id == payload.user_message_id,
|
||||||
|
PlaygroundMessage.role == "user",
|
||||||
|
PlaygroundMessage.is_visible.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user_message = result.scalar_one_or_none()
|
||||||
|
if user_message is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User message not found")
|
||||||
|
|
||||||
|
later_messages = await db.execute(
|
||||||
|
select(PlaygroundMessage).where(
|
||||||
|
PlaygroundMessage.session_id == session.id,
|
||||||
|
PlaygroundMessage.sort_order > user_message.sort_order,
|
||||||
|
PlaygroundMessage.is_visible.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for item in later_messages.scalars().all():
|
||||||
|
item.is_visible = False
|
||||||
|
if item.status in {"pending", "thinking", "answering"}:
|
||||||
|
active_run = _ACTIVE_RUNS.get(item.public_id)
|
||||||
|
if active_run is not None:
|
||||||
|
active_run.stop_requested.set()
|
||||||
|
active_run.task.cancel()
|
||||||
|
await db.flush()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
session_state = PlaygroundSessionState.model_validate(session.state or {})
|
||||||
|
create_payload = PlaygroundMessageCreateRequest(
|
||||||
|
session_key=payload.session_key,
|
||||||
|
title=session_state.title or session.title,
|
||||||
|
objective=session_state.objective or "继续当前对话",
|
||||||
|
constraints=session_state.constraints or "",
|
||||||
|
input=user_message.content,
|
||||||
|
selected_preset_key=session_state.selectedPresetKey or "bgp-brief",
|
||||||
|
help_expanded=session_state.helpExpanded,
|
||||||
|
)
|
||||||
|
return await _create_assistant_retry_turn(
|
||||||
|
db,
|
||||||
|
user_id=user_id,
|
||||||
|
session=session,
|
||||||
|
user_message=user_message,
|
||||||
|
payload=create_payload,
|
||||||
|
provider_client=provider_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def edit_user_message(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
payload: PlaygroundMessageEditRequest,
|
||||||
|
) -> PlaygroundMessageActionResponse:
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlaygroundSession).where(
|
||||||
|
PlaygroundSession.user_id == user_id,
|
||||||
|
PlaygroundSession.session_key == payload.session_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Playground session not found")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlaygroundMessage).where(
|
||||||
|
PlaygroundMessage.user_id == user_id,
|
||||||
|
PlaygroundMessage.public_id == payload.user_message_id,
|
||||||
|
PlaygroundMessage.role == "user",
|
||||||
|
PlaygroundMessage.is_visible.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user_message = result.scalar_one_or_none()
|
||||||
|
if user_message is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User message not found")
|
||||||
|
|
||||||
|
user_message.content = payload.content.strip()
|
||||||
|
await db.flush()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user_message)
|
||||||
|
|
||||||
|
thread = await _build_thread_response(db, session=session)
|
||||||
|
return PlaygroundMessageActionResponse(
|
||||||
|
session=thread.session,
|
||||||
|
messages=thread.messages,
|
||||||
|
active_message_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _append_meta_if_missing(db: AsyncSession, message_id: int, meta_line: str) -> None:
|
||||||
|
result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == message_id))
|
||||||
|
message = result.scalar_one_or_none()
|
||||||
|
if message is None:
|
||||||
|
return
|
||||||
|
if meta_line not in (message.meta or []):
|
||||||
|
message.meta = [*(message.meta or []), meta_line]
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def _should_stop(message_public_id: str) -> bool:
|
||||||
|
active_run = _ACTIVE_RUNS.get(message_public_id)
|
||||||
|
return active_run.stop_requested.is_set() if active_run is not None else False
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_message_state(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
message_id: int,
|
||||||
|
**updates,
|
||||||
|
) -> PlaygroundMessage:
|
||||||
|
result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == message_id))
|
||||||
|
message = result.scalar_one()
|
||||||
|
for key, value in updates.items():
|
||||||
|
setattr(message, key, value)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(message)
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def _build_conversation_history(messages: Sequence[PlaygroundMessage], current_user_message_id: int) -> list[dict]:
|
||||||
|
history: list[dict] = []
|
||||||
|
for item in messages:
|
||||||
|
if item.id >= current_user_message_id:
|
||||||
|
break
|
||||||
|
if item.role == "system":
|
||||||
|
continue
|
||||||
|
history.append(
|
||||||
|
{
|
||||||
|
"role": item.role,
|
||||||
|
"kind": item.kind or "message",
|
||||||
|
"title": item.title,
|
||||||
|
"content": item.content or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return history[-8:]
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_assistant_message(
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
session_id: int,
|
||||||
|
session_key: str,
|
||||||
|
user_message_id: int,
|
||||||
|
assistant_message_id: int,
|
||||||
|
payload: PlaygroundMessageCreateRequest,
|
||||||
|
provider_client: AIProviderClient,
|
||||||
|
) -> None:
|
||||||
|
request_id = str(uuid4())
|
||||||
|
started_at = perf_counter()
|
||||||
|
assistant_public_id: str | None = None
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
session = await db.get(PlaygroundSession, session_id)
|
||||||
|
user_message = await db.get(PlaygroundMessage, user_message_id)
|
||||||
|
assistant_message = await db.get(PlaygroundMessage, assistant_message_id)
|
||||||
|
if session is None or user_message is None or assistant_message is None:
|
||||||
|
return
|
||||||
|
assistant_public_id = assistant_message.public_id
|
||||||
|
|
||||||
|
visible_messages = await _list_visible_messages(db, session_id=session_id)
|
||||||
|
conversation_history = _build_conversation_history(visible_messages, user_message_id)
|
||||||
|
|
||||||
|
request_payload = SituationalAnalysisRequest(
|
||||||
|
title=payload.title,
|
||||||
|
objective=payload.objective,
|
||||||
|
observations=[item.strip() for item in payload.input.split("\n") if item.strip()],
|
||||||
|
constraints=_collect_constraints(payload.constraints),
|
||||||
|
context={
|
||||||
|
"source": "playground",
|
||||||
|
"preset": payload.selected_preset_key,
|
||||||
|
"conversation_history": conversation_history,
|
||||||
|
"history_size": len(conversation_history),
|
||||||
|
},
|
||||||
|
thinking={"type": "enabled"},
|
||||||
|
)
|
||||||
|
|
||||||
|
analysis = await provider_client.analyze(request_payload, request_id=request_id)
|
||||||
|
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
assistant_message = await _mark_message_state(
|
||||||
|
db,
|
||||||
|
message_id=assistant_message_id,
|
||||||
|
status="thinking" if analysis.thinking_blocks else "answering",
|
||||||
|
title=f"{analysis.provider} / {analysis.model}",
|
||||||
|
provider=analysis.provider,
|
||||||
|
model=analysis.model,
|
||||||
|
request_id=request_id,
|
||||||
|
raw_response=analysis.raw_response,
|
||||||
|
content_blocks=[item.model_dump(mode="json") for item in analysis.content_blocks],
|
||||||
|
text_blocks=analysis.text_blocks,
|
||||||
|
thinking_blocks=analysis.thinking_blocks,
|
||||||
|
thinking_content="\n\n".join(analysis.thinking_blocks).strip(),
|
||||||
|
)
|
||||||
|
session = await db.get(PlaygroundSession, session_id)
|
||||||
|
if session is not None:
|
||||||
|
session_state = PlaygroundSessionState.model_validate(session.state or {})
|
||||||
|
session.state = session_state.model_copy(
|
||||||
|
update={
|
||||||
|
"latestAnalysisMessageId": assistant_message.public_id,
|
||||||
|
"analysis": analysis.model_dump(mode="json"),
|
||||||
|
}
|
||||||
|
).model_dump(mode="json")
|
||||||
|
await db.flush()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
if assistant_public_id and analysis.thinking_blocks:
|
||||||
|
await asyncio.sleep(THINKING_PREVIEW_SECONDS)
|
||||||
|
if await _should_stop(assistant_public_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
content = analysis.content or ""
|
||||||
|
cursor = 0
|
||||||
|
while cursor < len(content):
|
||||||
|
if assistant_public_id and await _should_stop(assistant_public_id):
|
||||||
|
return
|
||||||
|
cursor = min(len(content), cursor + STREAM_CHUNK_SIZE)
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
await _mark_message_state(
|
||||||
|
db,
|
||||||
|
message_id=assistant_message_id,
|
||||||
|
status="answering",
|
||||||
|
content=content[:cursor],
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await asyncio.sleep(STREAM_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
duration_ms = round((perf_counter() - started_at) * 1000)
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
assistant_message = await _mark_message_state(
|
||||||
|
db,
|
||||||
|
message_id=assistant_message_id,
|
||||||
|
status="done",
|
||||||
|
content=content,
|
||||||
|
meta=[
|
||||||
|
f"Request ID: {request_id}",
|
||||||
|
f"耗时: {duration_ms} ms",
|
||||||
|
f"完成时间: {datetime.now(UTC).astimezone().isoformat()}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
session = await db.get(PlaygroundSession, session_id)
|
||||||
|
if session is not None:
|
||||||
|
session_state = PlaygroundSessionState.model_validate(session.state or {})
|
||||||
|
session.state = session_state.model_copy(
|
||||||
|
update={
|
||||||
|
"latestAnalysisMessageId": assistant_message.public_id,
|
||||||
|
"analysis": analysis.model_dump(mode="json"),
|
||||||
|
"analysisMeta": {
|
||||||
|
"requestId": request_id,
|
||||||
|
"durationMs": duration_ms,
|
||||||
|
"completedAt": datetime.now().isoformat(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).model_dump(mode="json")
|
||||||
|
await db.flush()
|
||||||
|
await db.commit()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == assistant_message_id))
|
||||||
|
message = result.scalar_one_or_none()
|
||||||
|
if message is not None and message.status in {"pending", "thinking", "answering"}:
|
||||||
|
message.status = "stopped"
|
||||||
|
if "已手动停止生成" not in (message.meta or []):
|
||||||
|
message.meta = [*(message.meta or []), "已手动停止生成"]
|
||||||
|
await db.flush()
|
||||||
|
await db.commit()
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == assistant_message_id))
|
||||||
|
message = result.scalar_one_or_none()
|
||||||
|
if message is not None:
|
||||||
|
message.status = "error"
|
||||||
|
message.content = message.content or "分析失败,请检查 AI Provider 配置或稍后再试。"
|
||||||
|
message.meta = [*(message.meta or []), f"错误: {type(exc).__name__}"]
|
||||||
|
await db.flush()
|
||||||
|
await db.commit()
|
||||||
|
finally:
|
||||||
|
if assistant_public_id:
|
||||||
|
_ACTIVE_RUNS.pop(assistant_public_id, None)
|
||||||
72
backend/app/services/playground_session_store.py
Normal file
72
backend/app/services/playground_session_store.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.playground_session import PlaygroundSession
|
||||||
|
from app.schemas.ai import (
|
||||||
|
PlaygroundSessionResponse,
|
||||||
|
PlaygroundSessionState,
|
||||||
|
PlaygroundSessionUpsertRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_response(record: PlaygroundSession) -> PlaygroundSessionResponse:
|
||||||
|
return PlaygroundSessionResponse(
|
||||||
|
id=str(record.id),
|
||||||
|
session_key=record.session_key,
|
||||||
|
title=record.title,
|
||||||
|
state=PlaygroundSessionState.model_validate(record.state or {}),
|
||||||
|
created_at=record.created_at.isoformat(),
|
||||||
|
updated_at=record.updated_at.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_playground_session(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
session_key: str = "default",
|
||||||
|
) -> PlaygroundSessionResponse | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlaygroundSession).where(
|
||||||
|
PlaygroundSession.user_id == user_id,
|
||||||
|
PlaygroundSession.session_key == session_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
return _to_response(record)
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_playground_session(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
payload: PlaygroundSessionUpsertRequest,
|
||||||
|
) -> PlaygroundSessionResponse:
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlaygroundSession).where(
|
||||||
|
PlaygroundSession.user_id == user_id,
|
||||||
|
PlaygroundSession.session_key == payload.session_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
title = (payload.title or payload.state.title or "Playground 会话").strip()[:200] or "Playground 会话"
|
||||||
|
|
||||||
|
if record is None:
|
||||||
|
record = PlaygroundSession(
|
||||||
|
user_id=user_id,
|
||||||
|
session_key=payload.session_key,
|
||||||
|
title=title,
|
||||||
|
state=payload.state.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
else:
|
||||||
|
record.title = title
|
||||||
|
record.state = payload.state.model_dump(mode="json")
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(record)
|
||||||
|
return _to_response(record)
|
||||||
174
backend/app/services/situational_alert_ai_brief.py
Normal file
174
backend/app/services/situational_alert_ai_brief.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.alert import Alert, AlertSeverity, AlertStatus
|
||||||
|
from app.models.bgp_anomaly import BGPAnomaly
|
||||||
|
from app.models.bgp_incident import BGPIncident
|
||||||
|
from app.schemas.ai import SituationalAnalysisRequest
|
||||||
|
from app.services.bgp_ai_brief_store import get_latest_bgp_brief_record
|
||||||
|
|
||||||
|
|
||||||
|
def _format_pairs(pairs: list[tuple[str, int]], empty_text: str = "无") -> str:
|
||||||
|
if not pairs:
|
||||||
|
return empty_text
|
||||||
|
return ",".join(f"{key} {value}" for key, value in pairs if key)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_situational_alert_brief_request(
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> tuple[SituationalAnalysisRequest, list[str], dict[str, Any]]:
|
||||||
|
total_alerts_result = await db.execute(select(func.count(Alert.id)))
|
||||||
|
active_alerts_result = await db.execute(
|
||||||
|
select(func.count(Alert.id)).where(Alert.status == AlertStatus.ACTIVE)
|
||||||
|
)
|
||||||
|
alert_severity_result = await db.execute(
|
||||||
|
select(Alert.severity, func.count(Alert.id))
|
||||||
|
.where(Alert.status == AlertStatus.ACTIVE)
|
||||||
|
.group_by(Alert.severity)
|
||||||
|
)
|
||||||
|
alert_source_result = await db.execute(
|
||||||
|
select(Alert.datasource_name, func.count(Alert.id))
|
||||||
|
.where(Alert.status == AlertStatus.ACTIVE)
|
||||||
|
.group_by(Alert.datasource_name)
|
||||||
|
.order_by(func.count(Alert.id).desc())
|
||||||
|
.limit(6)
|
||||||
|
)
|
||||||
|
recent_alerts_result = await db.execute(
|
||||||
|
select(Alert)
|
||||||
|
.order_by(Alert.created_at.desc(), Alert.id.desc())
|
||||||
|
.limit(6)
|
||||||
|
)
|
||||||
|
|
||||||
|
total_incidents_result = await db.execute(select(func.count(BGPIncident.id)))
|
||||||
|
active_incidents_result = await db.execute(
|
||||||
|
select(func.count(BGPIncident.id)).where(BGPIncident.status == "active")
|
||||||
|
)
|
||||||
|
bgp_severity_result = await db.execute(
|
||||||
|
select(BGPIncident.severity, func.count(BGPIncident.id))
|
||||||
|
.where(BGPIncident.status == "active")
|
||||||
|
.group_by(BGPIncident.severity)
|
||||||
|
)
|
||||||
|
bgp_region_counter: Counter[str] = Counter()
|
||||||
|
recent_incidents_result = await db.execute(
|
||||||
|
select(BGPIncident)
|
||||||
|
.order_by(BGPIncident.created_at.desc(), BGPIncident.id.desc())
|
||||||
|
.limit(5)
|
||||||
|
)
|
||||||
|
|
||||||
|
total_anomalies_result = await db.execute(select(func.count(BGPAnomaly.id)))
|
||||||
|
active_anomalies_result = await db.execute(
|
||||||
|
select(func.count(BGPAnomaly.id)).where(BGPAnomaly.status == "active")
|
||||||
|
)
|
||||||
|
anomaly_type_result = await db.execute(
|
||||||
|
select(BGPAnomaly.anomaly_type, func.count(BGPAnomaly.id))
|
||||||
|
.where(BGPAnomaly.status == "active")
|
||||||
|
.group_by(BGPAnomaly.anomaly_type)
|
||||||
|
.order_by(func.count(BGPAnomaly.id).desc())
|
||||||
|
.limit(6)
|
||||||
|
)
|
||||||
|
|
||||||
|
recent_incidents = recent_incidents_result.scalars().all()
|
||||||
|
for incident in recent_incidents:
|
||||||
|
for region in incident.affected_regions or []:
|
||||||
|
if not isinstance(region, dict):
|
||||||
|
continue
|
||||||
|
label = ", ".join(part for part in [region.get("city"), region.get("country")] if part) or "未知区域"
|
||||||
|
bgp_region_counter[label] += 1
|
||||||
|
|
||||||
|
latest_bgp_brief = get_latest_bgp_brief_record()
|
||||||
|
active_alert_severities = [
|
||||||
|
(item[0].value if isinstance(item[0], AlertSeverity) else str(item[0]), item[1])
|
||||||
|
for item in alert_severity_result.fetchall()
|
||||||
|
if item[0]
|
||||||
|
]
|
||||||
|
active_bgp_severities = [
|
||||||
|
(str(item[0]), item[1])
|
||||||
|
for item in bgp_severity_result.fetchall()
|
||||||
|
if item[0]
|
||||||
|
]
|
||||||
|
active_anomaly_types = [(str(item[0]), item[1]) for item in anomaly_type_result.fetchall() if item[0]]
|
||||||
|
active_alert_sources = [
|
||||||
|
(str(item[0] or "未命名数据源"), item[1])
|
||||||
|
for item in alert_source_result.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
facts = [
|
||||||
|
(
|
||||||
|
f"系统告警侧:总告警 {total_alerts_result.scalar() or 0} 条,active {active_alerts_result.scalar() or 0} 条;"
|
||||||
|
f"活跃告警严重度分布为 {_format_pairs(active_alert_severities)}。"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"BGP态势侧:累计 incidents {total_incidents_result.scalar() or 0} 条,active incidents {active_incidents_result.scalar() or 0} 条;"
|
||||||
|
f"活跃 incidents 严重度分布为 {_format_pairs(active_bgp_severities)}。"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"BGP异常侧:累计 anomalies {total_anomalies_result.scalar() or 0} 条,active anomalies {active_anomalies_result.scalar() or 0} 条;"
|
||||||
|
f"活跃 anomaly 类型分布为 {_format_pairs(active_anomaly_types)}。"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
if active_alert_sources:
|
||||||
|
facts.append(f"当前系统告警主要集中在:{_format_pairs(active_alert_sources)}。")
|
||||||
|
if bgp_region_counter:
|
||||||
|
facts.append(f"BGP近期高风险区域线索:{_format_pairs(bgp_region_counter.most_common(5))}。")
|
||||||
|
|
||||||
|
recent_alerts = recent_alerts_result.scalars().all()
|
||||||
|
if recent_alerts:
|
||||||
|
facts.append(
|
||||||
|
"最近系统告警摘录:"
|
||||||
|
+ ";".join(
|
||||||
|
[
|
||||||
|
f"{alert.datasource_name or '未命名数据源'} / {alert.severity.value if alert.severity else '-'} / {alert.status.value if alert.status else '-'} / {alert.message or '-'}"
|
||||||
|
for alert in recent_alerts
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if recent_incidents:
|
||||||
|
facts.append(
|
||||||
|
"最近BGP事件摘录:"
|
||||||
|
+ ";".join(
|
||||||
|
[
|
||||||
|
f"{incident.incident_type} / {incident.severity} / {incident.status} / {incident.summary}"
|
||||||
|
for incident in recent_incidents
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if latest_bgp_brief:
|
||||||
|
facts.append(
|
||||||
|
f"最近一份 BGP AI 简报生成于 {latest_bgp_brief.generated_at},模型 {latest_bgp_brief.model},可作为当前态势的补充说明。"
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"source": "situational-alerts",
|
||||||
|
"active_system_alerts": active_alerts_result.scalar() or 0,
|
||||||
|
"active_system_alert_severities": dict(active_alert_severities),
|
||||||
|
"top_system_alert_sources": dict(active_alert_sources),
|
||||||
|
"active_bgp_incidents": active_incidents_result.scalar() or 0,
|
||||||
|
"active_bgp_incident_severities": dict(active_bgp_severities),
|
||||||
|
"active_bgp_anomalies": active_anomalies_result.scalar() or 0,
|
||||||
|
"active_bgp_anomaly_types": dict(active_anomaly_types),
|
||||||
|
"bgp_hot_regions": dict(bgp_region_counter.most_common(5)),
|
||||||
|
"latest_bgp_brief_id": latest_bgp_brief.id if latest_bgp_brief else None,
|
||||||
|
"latest_bgp_brief_generated_at": latest_bgp_brief.generated_at if latest_bgp_brief else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
request = SituationalAnalysisRequest(
|
||||||
|
title="态势告警 AI 简报",
|
||||||
|
objective="综合系统告警、BGP incidents、BGP anomalies 与近期 BGP AI 简报,生成一份面向值班人员的态势告警简报,指出当前最需要关注的风险域、跨模块联动迹象和优先动作。",
|
||||||
|
observations=facts,
|
||||||
|
constraints=[
|
||||||
|
"明确区分事实、推断与建议。",
|
||||||
|
"优先指出仍在 active 状态的系统告警与 BGP 风险是否存在联动。",
|
||||||
|
"不要把单一数据源的局部异常夸大成全局态势。",
|
||||||
|
"如果证据不足,请明确写出仍缺哪些模块或区域信息。",
|
||||||
|
],
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
return request, facts, context
|
||||||
@@ -10,7 +10,12 @@ from app.core.config import settings
|
|||||||
from app.core.security import create_access_token
|
from app.core.security import create_access_token
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.ai import AIProviderStatusResponse, SituationalAnalysisResponse
|
from app.schemas.ai import (
|
||||||
|
AIProviderStatusResponse,
|
||||||
|
PlaygroundSessionResponse,
|
||||||
|
PlaygroundSessionState,
|
||||||
|
SituationalAnalysisResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -258,5 +263,294 @@ async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers):
|
|||||||
assert "content_blocks" in data
|
assert "content_blocks" in data
|
||||||
assert "text_blocks" in data
|
assert "text_blocks" in data
|
||||||
assert "thinking_blocks" in data
|
assert "thinking_blocks" in data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_playground_session_with_auth(auth_headers):
|
||||||
|
"""Test playground session restore endpoint."""
|
||||||
|
|
||||||
|
def override_get_current_user():
|
||||||
|
return User(
|
||||||
|
id=1,
|
||||||
|
username="testuser",
|
||||||
|
email="test@example.com",
|
||||||
|
password_hash="hashed",
|
||||||
|
role="admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
yield AsyncMock()
|
||||||
|
|
||||||
|
app.dependency_overrides = {
|
||||||
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||||
|
get_db: override_get_db,
|
||||||
|
}
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
try:
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.ai.get_playground_session",
|
||||||
|
new=AsyncMock(
|
||||||
|
return_value=PlaygroundSessionResponse(
|
||||||
|
id="1",
|
||||||
|
session_key="default",
|
||||||
|
title="Playground 会话",
|
||||||
|
state=PlaygroundSessionState(
|
||||||
|
messages=[{"id": "msg-1", "role": "user", "content": "hello"}],
|
||||||
|
title="测试标题",
|
||||||
|
objective="测试目标",
|
||||||
|
),
|
||||||
|
created_at="2026-04-10T00:00:00+00:00",
|
||||||
|
updated_at="2026-04-10T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/api/v1/ai/playground/session", headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["session_key"] == "default"
|
||||||
|
assert data["state"]["messages"][0]["content"] == "hello"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_playground_session_with_auth(auth_headers):
|
||||||
|
"""Test playground session save endpoint."""
|
||||||
|
|
||||||
|
def override_get_current_user():
|
||||||
|
return User(
|
||||||
|
id=1,
|
||||||
|
username="testuser",
|
||||||
|
email="test@example.com",
|
||||||
|
password_hash="hashed",
|
||||||
|
role="admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
yield AsyncMock()
|
||||||
|
|
||||||
|
app.dependency_overrides = {
|
||||||
|
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||||
|
get_db: override_get_db,
|
||||||
|
}
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
try:
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.ai.upsert_playground_session",
|
||||||
|
new=AsyncMock(
|
||||||
|
return_value=PlaygroundSessionResponse(
|
||||||
|
id="1",
|
||||||
|
session_key="default",
|
||||||
|
title="测试标题",
|
||||||
|
state=PlaygroundSessionState(
|
||||||
|
messages=[{"id": "msg-1", "role": "user", "content": "hello"}],
|
||||||
|
title="测试标题",
|
||||||
|
objective="测试目标",
|
||||||
|
),
|
||||||
|
created_at="2026-04-10T00:00:00+00:00",
|
||||||
|
updated_at="2026-04-10T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
):
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.put(
|
||||||
|
"/api/v1/ai/playground/session",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"session_key": "default",
|
||||||
|
"title": "测试标题",
|
||||||
|
"state": {
|
||||||
|
"messages": [{"id": "msg-1", "role": "user", "content": "hello"}],
|
||||||
|
"selectedPresetKey": "bgp-brief",
|
||||||
|
"title": "测试标题",
|
||||||
|
"objective": "测试目标",
|
||||||
|
"constraints": "",
|
||||||
|
"inputValue": "",
|
||||||
|
"analysis": None,
|
||||||
|
"latestAnalysisMessageId": None,
|
||||||
|
"analysisMeta": {},
|
||||||
|
"helpExpanded": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["title"] == "测试标题"
|
||||||
|
assert data["state"]["objective"] == "测试目标"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ai_bgp_brief_endpoint_persists_fact_snapshot(auth_headers):
|
||||||
|
class _FakeAIProviderClient:
|
||||||
|
async def analyze(self, _payload, request_id=None):
|
||||||
|
return SituationalAnalysisResponse(
|
||||||
|
provider="minimax",
|
||||||
|
model="MiniMax-M2.5",
|
||||||
|
content="# BGP AI 简报\n\n事实摘要:测试",
|
||||||
|
content_blocks=[],
|
||||||
|
text_blocks=["# BGP AI 简报\n\n事实摘要:测试"],
|
||||||
|
thinking_blocks=[],
|
||||||
|
raw_response={"id": "mock-bgp-brief"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def override_get_current_user():
|
||||||
|
return User(
|
||||||
|
id=1,
|
||||||
|
username="testuser",
|
||||||
|
email="test@example.com",
|
||||||
|
password_hash="hashed",
|
||||||
|
role="admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
yield AsyncMock()
|
||||||
|
|
||||||
|
async def _fake_build_bgp_brief_request(_db, **_kwargs):
|
||||||
|
request_payload = __import__("app.schemas.ai", fromlist=["SituationalAnalysisRequest"]).SituationalAnalysisRequest(
|
||||||
|
title="BGP 态势 AI 简报",
|
||||||
|
objective="生成值班简报",
|
||||||
|
observations=["事实A", "事实B"],
|
||||||
|
constraints=["不要编造"],
|
||||||
|
context={"incident_total": 2, "active_collectors": 3},
|
||||||
|
)
|
||||||
|
return request_payload, ["事实A", "事实B"], {"incident_total": 2, "active_collectors": 3}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
get_db: override_get_db,
|
||||||
|
}
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
try:
|
||||||
|
with patch("app.api.v1.ai.build_bgp_brief_request", side_effect=_fake_build_bgp_brief_request):
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.post("/api/v1/ai/bgp/brief", headers=auth_headers, json={})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["facts"] == ["事实A", "事实B"]
|
||||||
|
assert data["context"]["incident_total"] == 2
|
||||||
|
assert data["content_markdown"]
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ai_alert_brief_endpoint_with_auth(auth_headers):
|
||||||
|
class _FakeAIProviderClient:
|
||||||
|
async def analyze(self, _payload, request_id=None):
|
||||||
|
return SituationalAnalysisResponse(
|
||||||
|
provider="minimax",
|
||||||
|
model="MiniMax-M2.7",
|
||||||
|
content="事实摘要:告警测试。风险研判:告警测试。建议动作:告警测试。",
|
||||||
|
content_blocks=[],
|
||||||
|
text_blocks=["事实摘要:告警测试。风险研判:告警测试。建议动作:告警测试。"],
|
||||||
|
thinking_blocks=[],
|
||||||
|
raw_response={"id": "mock-alert-brief"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def override_get_current_user():
|
||||||
|
return User(
|
||||||
|
id=1,
|
||||||
|
username="testuser",
|
||||||
|
email="test@example.com",
|
||||||
|
password_hash="hashed",
|
||||||
|
role="admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
yield AsyncMock()
|
||||||
|
|
||||||
|
async def _fake_build_alert_brief_request(_db, **_kwargs):
|
||||||
|
request_payload = __import__("app.schemas.ai", fromlist=["SituationalAnalysisRequest"]).SituationalAnalysisRequest(
|
||||||
|
title="告警态势 AI 简报",
|
||||||
|
objective="输出告警简报",
|
||||||
|
observations=["告警事实A", "告警事实B"],
|
||||||
|
constraints=["不要编造"],
|
||||||
|
context={"active_alerts": 3, "top_datasources": {"bgp": 2}},
|
||||||
|
)
|
||||||
|
return request_payload, ["告警事实A", "告警事实B"], {"active_alerts": 3, "top_datasources": {"bgp": 2}}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
get_db: override_get_db,
|
||||||
|
}
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
try:
|
||||||
|
with patch("app.api.v1.ai.build_alert_brief_request", side_effect=_fake_build_alert_brief_request):
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.post("/api/v1/ai/alerts/brief", headers=auth_headers, json={})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["title"] == "告警态势 AI 简报"
|
||||||
|
assert data["facts"] == ["告警事实A", "告警事实B"]
|
||||||
|
assert data["context"]["active_alerts"] == 3
|
||||||
|
assert data["content"]
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ai_situational_alert_brief_endpoint_with_auth(auth_headers):
|
||||||
|
class _FakeAIProviderClient:
|
||||||
|
async def analyze(self, _payload, request_id=None):
|
||||||
|
return SituationalAnalysisResponse(
|
||||||
|
provider="minimax",
|
||||||
|
model="MiniMax-M2.7",
|
||||||
|
content="事实摘要:态势测试。风险研判:态势测试。建议动作:态势测试。",
|
||||||
|
content_blocks=[],
|
||||||
|
text_blocks=["事实摘要:态势测试。风险研判:态势测试。建议动作:态势测试。"],
|
||||||
|
thinking_blocks=[],
|
||||||
|
raw_response={"id": "mock-situational-brief"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def override_get_current_user():
|
||||||
|
return User(
|
||||||
|
id=1,
|
||||||
|
username="testuser",
|
||||||
|
email="test@example.com",
|
||||||
|
password_hash="hashed",
|
||||||
|
role="admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
yield AsyncMock()
|
||||||
|
|
||||||
|
async def _fake_build_situational_alert_brief_request(_db):
|
||||||
|
request_payload = __import__("app.schemas.ai", fromlist=["SituationalAnalysisRequest"]).SituationalAnalysisRequest(
|
||||||
|
title="态势告警 AI 简报",
|
||||||
|
objective="输出态势告警简报",
|
||||||
|
observations=["态势事实A", "态势事实B"],
|
||||||
|
constraints=["不要编造"],
|
||||||
|
context={"active_system_alerts": 2, "active_bgp_incidents": 1},
|
||||||
|
)
|
||||||
|
return request_payload, ["态势事实A", "态势事实B"], {"active_system_alerts": 2, "active_bgp_incidents": 1}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
get_db: override_get_db,
|
||||||
|
}
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
try:
|
||||||
|
with patch("app.api.v1.ai.build_situational_alert_brief_request", side_effect=_fake_build_situational_alert_brief_request):
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.post("/api/v1/ai/situational-alerts/brief", headers=auth_headers, json={})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["title"] == "态势告警 AI 简报"
|
||||||
|
assert data["facts"] == ["态势事实A", "态势事实B"]
|
||||||
|
assert data["context"]["active_system_alerts"] == 2
|
||||||
|
assert data["content"]
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|||||||
309
docs/situational-awareness-foundation-plan.md
Normal file
309
docs/situational-awareness-foundation-plan.md
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
# Situational Awareness Foundation Plan
|
||||||
|
|
||||||
|
## 定位
|
||||||
|
|
||||||
|
当前这套 AI 能力应被视为 `态势感知服务底座`,而不是完整的态势感知产品。
|
||||||
|
|
||||||
|
也就是说,现阶段的目标不是:
|
||||||
|
|
||||||
|
- 做一个“什么都能分析”的万能 AI 页面
|
||||||
|
- 让模型在证据不足时替代人工研判
|
||||||
|
- 过早把页面做成完整指挥大屏
|
||||||
|
|
||||||
|
现阶段真正要做的是:
|
||||||
|
|
||||||
|
- 先把 `model gateway / backend facade / evidence injection / page-specific brief` 这几层边界搭稳
|
||||||
|
- 让系统能够在已有证据上稳定地产出“可读、可回看、可扩展”的摘要
|
||||||
|
- 为后续更强的数据联动、agent 推理和 assessment 结构化输出预留好接口与数据模型
|
||||||
|
|
||||||
|
## 当前现实约束
|
||||||
|
|
||||||
|
### 1. 数据维度不足
|
||||||
|
|
||||||
|
目前系统能提供的主要证据仍集中在:
|
||||||
|
|
||||||
|
- BGP incidents / anomalies / events
|
||||||
|
- collector coverage
|
||||||
|
- datasource health / platform alerts
|
||||||
|
- prefix geography 的部分归属信息
|
||||||
|
|
||||||
|
当前明显还缺:
|
||||||
|
|
||||||
|
- 流量异常与业务指标
|
||||||
|
- 电商、支付、物流等业务侧指标
|
||||||
|
- 更丰富的资产、链路、区域、行业画像
|
||||||
|
- 外部舆情、公告、运营商状态、基础设施事件等背景信息
|
||||||
|
|
||||||
|
这意味着:
|
||||||
|
|
||||||
|
- 模型现在可以做“基于现有证据的摘要与归纳”
|
||||||
|
- 但还不能可靠地做“跨维度因果研判”
|
||||||
|
|
||||||
|
### 2. 维度之间联动还弱
|
||||||
|
|
||||||
|
目前不同模块之间更多是“并列展示”,还不是“强关联分析”:
|
||||||
|
|
||||||
|
- 系统告警和 BGP 事件还没有统一事件模型
|
||||||
|
- collector bias 与真实区域热度还没有完全剥离
|
||||||
|
- datasource health 与 BGP 风险、业务影响之间还没有稳定映射
|
||||||
|
|
||||||
|
这意味着:
|
||||||
|
|
||||||
|
- 当前更适合做 `brief / overview / operator notes`
|
||||||
|
- 还不适合过度承诺“自动态势判断”
|
||||||
|
|
||||||
|
### 3. 结构化 assessment 还未成为主输出
|
||||||
|
|
||||||
|
虽然已经有 BGP brief、系统告警 brief、态势告警 brief,但目前主输出仍偏向:
|
||||||
|
|
||||||
|
- 文本摘要
|
||||||
|
- facts/context 附带证据
|
||||||
|
|
||||||
|
后续真正要服务态势感知,需要更稳定的结构化输出,例如:
|
||||||
|
|
||||||
|
- summary
|
||||||
|
- key risks
|
||||||
|
- evidence
|
||||||
|
- confidence
|
||||||
|
- recommendations
|
||||||
|
- missing data
|
||||||
|
|
||||||
|
## 当前基座已经具备的能力
|
||||||
|
|
||||||
|
### 1. AI 调用边界已经明确
|
||||||
|
|
||||||
|
- `aiprovider` 负责模型协议与 provider 兼容
|
||||||
|
- `backend` 负责业务 API、证据整合和鉴权
|
||||||
|
- `frontend` 负责页面入口与结果展示
|
||||||
|
|
||||||
|
### 2. 页面级 AI 入口已经开始成型
|
||||||
|
|
||||||
|
当前已经有或正在收口的入口:
|
||||||
|
|
||||||
|
- `Playground`
|
||||||
|
- 用于链路验证与 provider 诊断
|
||||||
|
- `BGP AI 简报`
|
||||||
|
- 用于 BGP 事实摘要和区域风险归纳
|
||||||
|
- `Alerts`
|
||||||
|
- 用于系统告警、BGP 告警、态势告警三类入口
|
||||||
|
|
||||||
|
### 3. 证据优先的方向已经建立
|
||||||
|
|
||||||
|
已经不再只依赖人工在 Playground 中手填 prompt,系统开始具备:
|
||||||
|
|
||||||
|
- 从真实业务数据生成事实输入
|
||||||
|
- 保存 facts/context 快照
|
||||||
|
- 回看 AI 输出时同时回看证据
|
||||||
|
|
||||||
|
这一步非常关键,因为它决定后面能否从“玩具 demo”走向“有运维价值的系统”。
|
||||||
|
|
||||||
|
## 近期收尾建议
|
||||||
|
|
||||||
|
这些事情都属于“底座收口”,值得做,但不应该再继续重产品包装。
|
||||||
|
|
||||||
|
### 1. 统一 Alerts 页面
|
||||||
|
|
||||||
|
已采用:
|
||||||
|
|
||||||
|
- 一个 `Alerts` 页面
|
||||||
|
- 三个 tab:
|
||||||
|
- `系统告警`
|
||||||
|
- `BGP 告警`
|
||||||
|
- `态势告警`
|
||||||
|
|
||||||
|
收尾重点:
|
||||||
|
|
||||||
|
- 保持 tab 的文案、摘要卡和 AI 简报交互一致
|
||||||
|
- 不额外扩展成多个独立二级页面
|
||||||
|
|
||||||
|
### 2. 保持 Playground 为测试台
|
||||||
|
|
||||||
|
原则:
|
||||||
|
|
||||||
|
- Playground 只承担链路验证、provider 状态诊断、请求结果观察
|
||||||
|
- 不继续堆“万能业务分析器”式交互
|
||||||
|
|
||||||
|
### 3. 把 brief 能力当服务能力而不是页面特效
|
||||||
|
|
||||||
|
页面现在能看到按钮和结果,这很好,但更重要的是:
|
||||||
|
|
||||||
|
- 后端接口稳定
|
||||||
|
- facts/context 可追踪
|
||||||
|
- 输出结构后续可升级
|
||||||
|
|
||||||
|
### 4. 导航结构先收口,不继续平铺一级菜单
|
||||||
|
|
||||||
|
随着后续能力扩展,系统很可能继续新增:
|
||||||
|
|
||||||
|
- 海缆
|
||||||
|
- 算力中心
|
||||||
|
- 战争信息
|
||||||
|
- 电商分析
|
||||||
|
- 其他专题观测页
|
||||||
|
|
||||||
|
如果继续把这些入口全部平铺在左侧一级菜单中,会带来两个问题:
|
||||||
|
|
||||||
|
- 一级菜单过长,用户难以判断先进入哪个上下文
|
||||||
|
- `观测页 / 告警页 / 研判页 / 运维页` 的职责边界会被混在一起
|
||||||
|
|
||||||
|
因此近期应明确采用分组导航,而不是继续扩展平铺菜单。
|
||||||
|
|
||||||
|
推荐的导航分组如下:
|
||||||
|
|
||||||
|
- `总览`
|
||||||
|
- 仪表盘
|
||||||
|
- Earth
|
||||||
|
- `专题观测`
|
||||||
|
- BGP 观测
|
||||||
|
- 采集数据
|
||||||
|
- 后续可扩展:海缆、算力中心、战争信息、电商分析
|
||||||
|
- `告警与研判`
|
||||||
|
- Alerts
|
||||||
|
- `运维与配置`
|
||||||
|
- 数据源
|
||||||
|
- AI Playground
|
||||||
|
- 用户管理
|
||||||
|
- 系统配置
|
||||||
|
|
||||||
|
这套结构的含义是:
|
||||||
|
|
||||||
|
- `专题观测` 页面负责看某个维度本身
|
||||||
|
- `Alerts` 负责跨模块风险与值班工作台
|
||||||
|
- `Playground` 保持为测试台,不挤占业务导航语义
|
||||||
|
|
||||||
|
短期收尾时,应优先重组现有入口,而不是继续增加新的一级菜单。
|
||||||
|
|
||||||
|
## 后续路线
|
||||||
|
|
||||||
|
## Phase 1:服务底座稳固
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 不追求“更炫的 AI 页面”
|
||||||
|
- 先把当前接口、证据、存储和页面入口收稳
|
||||||
|
|
||||||
|
工作项:
|
||||||
|
|
||||||
|
- 统一页面级 AI 入口模式
|
||||||
|
- 统一 brief response schema
|
||||||
|
- 保证 facts/context 在前后端都可回看
|
||||||
|
- 继续清理 mock 和临时分支逻辑
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 每个 AI 入口都是真实链路
|
||||||
|
- 每个 AI 结果都能追溯到证据输入
|
||||||
|
|
||||||
|
## Phase 2:Evidence-first Assessment
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 从“文本摘要”升级成“结构化 assessment”
|
||||||
|
|
||||||
|
工作项:
|
||||||
|
|
||||||
|
- 为 brief/assessment 定义统一 schema
|
||||||
|
- 固化:
|
||||||
|
- summary
|
||||||
|
- key_risks
|
||||||
|
- evidence
|
||||||
|
- confidence
|
||||||
|
- recommendations
|
||||||
|
- missing_data
|
||||||
|
- 页面以结构化区块展示,而不只是大段文本
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- AI 输出可持久化、可比较、可审计
|
||||||
|
|
||||||
|
## Phase 3:多维证据接入
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 让“态势感知”真正拥有更多维度,而不是只靠 BGP 与系统告警
|
||||||
|
|
||||||
|
优先接入方向:
|
||||||
|
|
||||||
|
- datasource health findings
|
||||||
|
- 流量或业务指标
|
||||||
|
- 区域/资产/链路映射
|
||||||
|
- 外部事件与公告
|
||||||
|
- 业务垂直数据,例如电商分析相关指标
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- AI 能基于多个维度做交叉说明
|
||||||
|
- 不再只围绕单一模块自说自话
|
||||||
|
|
||||||
|
## Phase 4:Correlation Layer
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 不同来源的信号不再只是并列,而是形成统一的事件关联
|
||||||
|
|
||||||
|
工作项:
|
||||||
|
|
||||||
|
- 统一 signal/finding 模型
|
||||||
|
- 跨模块事件聚合
|
||||||
|
- 证据来源权重
|
||||||
|
- collector bias 与真实热度分离
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 系统能回答“这些异常是不是同一件事”
|
||||||
|
- 系统能回答“哪些结论只是观测偏差”
|
||||||
|
|
||||||
|
## Phase 5:Agent-assisted Situational Awareness
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 在证据足够的前提下,再让 agent 负责更复杂的推理与建议
|
||||||
|
|
||||||
|
工作项:
|
||||||
|
|
||||||
|
- 复用现有 agent runtime 规划
|
||||||
|
- 引入 web search / docs fetch / repair proposal 等能力
|
||||||
|
- 但始终坚持:
|
||||||
|
- evidence first
|
||||||
|
- proposal before action
|
||||||
|
- no silent mutation of defaults
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- agent 成为证据驱动的分析层
|
||||||
|
- 而不是一个“万能猜测层”
|
||||||
|
|
||||||
|
## 设计原则
|
||||||
|
|
||||||
|
### 1. 先底座,后产品化
|
||||||
|
|
||||||
|
先把服务链路和证据模型做好,再做更大的页面表达。
|
||||||
|
|
||||||
|
### 2. 先证据,后判断
|
||||||
|
|
||||||
|
事实输入应先稳定,再让模型做归纳。
|
||||||
|
|
||||||
|
### 3. 先专用 brief,后统一态势层
|
||||||
|
|
||||||
|
先让各业务页有各自可信的 AI 入口,再考虑统一态势页。
|
||||||
|
|
||||||
|
### 4. 先 proposal,后自动动作
|
||||||
|
|
||||||
|
涉及修复、覆盖、写配置、调任务的动作,都应经过 proposal 和审计。
|
||||||
|
|
||||||
|
## 当前建议结论
|
||||||
|
|
||||||
|
对现在这个项目,最合理的定位是:
|
||||||
|
|
||||||
|
- `Playground` 是测试台
|
||||||
|
- `BGP / Alerts` 是第一批业务 AI 入口
|
||||||
|
- `aiprovider + backend AI facade + evidence snapshots` 是核心服务底座
|
||||||
|
|
||||||
|
现阶段不需要追求“已经具备完整态势感知能力”。
|
||||||
|
|
||||||
|
现阶段真正的成功标准是:
|
||||||
|
|
||||||
|
- 这套底座可用
|
||||||
|
- 可回看
|
||||||
|
- 可扩展
|
||||||
|
- 不自欺欺人
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { Suspense, lazy } from 'react'
|
import { Suspense, lazy } from 'react'
|
||||||
|
|
||||||
import { Spin } from 'antd'
|
import { Spin } from 'antd'
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
import Login from './pages/Login/Login'
|
import Login from './pages/Login/Login'
|
||||||
|
|
||||||
|
const SystemAlerts = lazy(() => import('./pages/Alerts/SystemAlerts'))
|
||||||
|
const BGPAlerts = lazy(() => import('./pages/Alerts/BGPAlerts'))
|
||||||
|
const SituationalAlerts = lazy(() => import('./pages/Alerts/SituationalAlerts'))
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard/Dashboard'))
|
const Dashboard = lazy(() => import('./pages/Dashboard/Dashboard'))
|
||||||
const Users = lazy(() => import('./pages/Users/Users'))
|
const Users = lazy(() => import('./pages/Users/Users'))
|
||||||
const DataSources = lazy(() => import('./pages/DataSources/DataSources'))
|
const DataSources = lazy(() => import('./pages/DataSources/DataSources'))
|
||||||
@@ -37,6 +42,10 @@ function App() {
|
|||||||
<Route path="/users" element={<Users />} />
|
<Route path="/users" element={<Users />} />
|
||||||
<Route path="/datasources" element={<DataSources />} />
|
<Route path="/datasources" element={<DataSources />} />
|
||||||
<Route path="/data" element={<DataList />} />
|
<Route path="/data" element={<DataList />} />
|
||||||
|
<Route path="/alerts" element={<Navigate to="/alerts/system" replace />} />
|
||||||
|
<Route path="/alerts/system" element={<SystemAlerts />} />
|
||||||
|
<Route path="/alerts/bgp" element={<BGPAlerts />} />
|
||||||
|
<Route path="/alerts/situational" element={<SituationalAlerts />} />
|
||||||
<Route path="/bgp" element={<BGP />} />
|
<Route path="/bgp" element={<BGP />} />
|
||||||
<Route path="/playground" element={<Playground />} />
|
<Route path="/playground" element={<Playground />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ReactNode, useState } from 'react'
|
import { ReactNode, useMemo, useState } from 'react'
|
||||||
import { Layout, Menu, Typography, Button, Space } from 'antd'
|
import { Layout, Menu, Typography, Button, Space } from 'antd'
|
||||||
import {
|
import {
|
||||||
|
AlertOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
@@ -10,8 +11,13 @@ import {
|
|||||||
RobotOutlined,
|
RobotOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
|
ToolOutlined,
|
||||||
|
InboxOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import packageJson from '../../../package.json'
|
import packageJson from '../../../package.json'
|
||||||
|
|
||||||
@@ -27,19 +33,64 @@ function AppLayout({ children }: AppLayoutProps) {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user, logout } = useAuthStore()
|
const { user, logout } = useAuthStore()
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [openKeys, setOpenKeys] = useState<string[]>(['collection'])
|
||||||
const showBanner = true
|
const showBanner = true
|
||||||
const appVersion = `v${packageJson.version}`
|
const appVersion = `v${packageJson.version}`
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems: ItemType<MenuItemType>[] = [
|
||||||
{ key: '/admin', icon: <DashboardOutlined />, label: '仪表盘' },
|
{
|
||||||
{ key: '/datasources', icon: <DatabaseOutlined />, label: '数据源' },
|
key: 'overview',
|
||||||
{ key: '/data', icon: <BarChartOutlined />, label: '采集数据' },
|
icon: <DashboardOutlined />,
|
||||||
{ key: '/bgp', icon: <DeploymentUnitOutlined />, label: 'BGP观测' },
|
label: '总览',
|
||||||
{ key: '/playground', icon: <RobotOutlined />, label: 'AI Playground' },
|
children: [
|
||||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
{ key: '/admin', icon: <DashboardOutlined />, label: '仪表盘' },
|
||||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
|
{ key: '/earth', icon: <GlobalOutlined />, label: 'Earth' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'collection',
|
||||||
|
icon: <InboxOutlined />,
|
||||||
|
label: '采集与数据',
|
||||||
|
children: [
|
||||||
|
{ key: '/datasources', icon: <DatabaseOutlined />, label: '数据源' },
|
||||||
|
{ key: '/data', icon: <BarChartOutlined />, label: '采集数据' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'observability',
|
||||||
|
icon: <AppstoreOutlined />,
|
||||||
|
label: '专题观测',
|
||||||
|
children: [
|
||||||
|
{ key: '/bgp', icon: <DeploymentUnitOutlined />, label: 'BGP观测' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'alerts',
|
||||||
|
icon: <AlertOutlined />,
|
||||||
|
label: '告警与研判',
|
||||||
|
children: [
|
||||||
|
{ key: '/alerts/system', icon: <AlertOutlined />, label: '系统告警' },
|
||||||
|
{ key: '/alerts/bgp', icon: <DeploymentUnitOutlined />, label: 'BGP 告警' },
|
||||||
|
{ key: '/alerts/situational', icon: <GlobalOutlined />, label: '态势告警' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ops',
|
||||||
|
icon: <ToolOutlined />,
|
||||||
|
label: '运维与配置',
|
||||||
|
children: [
|
||||||
|
{ key: '/playground', icon: <RobotOutlined />, label: 'AI Playground' },
|
||||||
|
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||||
|
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const selectedKey = useMemo(() => {
|
||||||
|
if (location.pathname === '/') return '/earth'
|
||||||
|
return location.pathname
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className="dashboard-layout">
|
<Layout className="dashboard-layout">
|
||||||
<Sider
|
<Sider
|
||||||
@@ -47,7 +98,12 @@ function AppLayout({ children }: AppLayoutProps) {
|
|||||||
collapsedWidth={72}
|
collapsedWidth={72}
|
||||||
collapsible
|
collapsible
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onCollapse={setCollapsed}
|
onCollapse={(nextCollapsed) => {
|
||||||
|
setCollapsed(nextCollapsed)
|
||||||
|
if (nextCollapsed) {
|
||||||
|
setOpenKeys([])
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="dashboard-sider"
|
className="dashboard-sider"
|
||||||
>
|
>
|
||||||
<div className="dashboard-sider-inner">
|
<div className="dashboard-sider-inner">
|
||||||
@@ -69,10 +125,14 @@ function AppLayout({ children }: AppLayoutProps) {
|
|||||||
<Menu
|
<Menu
|
||||||
theme="dark"
|
theme="dark"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[location.pathname]}
|
selectedKeys={[selectedKey]}
|
||||||
|
openKeys={collapsed ? [] : openKeys}
|
||||||
items={menuItems}
|
items={menuItems}
|
||||||
|
onOpenChange={(keys) => {
|
||||||
|
setOpenKeys(keys as string[])
|
||||||
|
}}
|
||||||
onClick={({ key }) => {
|
onClick={({ key }) => {
|
||||||
if (key !== location.pathname) {
|
if (key !== selectedKey) {
|
||||||
navigate(key)
|
navigate(key)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -190,6 +190,11 @@ body {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playground-page {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.playground-page__grid {
|
.playground-page__grid {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -198,11 +203,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.playground-page__body {
|
.playground-page__body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playground-shell {
|
.playground-shell {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -303,6 +312,7 @@ body {
|
|||||||
|
|
||||||
.playground-card--workspace {
|
.playground-card--workspace {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playground-card--workspace .ant-card-body,
|
.playground-card--workspace .ant-card-body,
|
||||||
@@ -310,6 +320,405 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playground-chat {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat .ant-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px 18px 18px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__messages {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding-right: 4px;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__messages-shell {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__scroll-bottom.ant-btn {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
bottom: 12px;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
color: #0f172a;
|
||||||
|
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__scroll-bottom.ant-btn:hover,
|
||||||
|
.playground-chat__scroll-bottom.ant-btn:focus {
|
||||||
|
background: #ffffff !important;
|
||||||
|
color: #0f766e !important;
|
||||||
|
border-color: rgba(15, 118, 110, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__composer {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__input-wrap {
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.98));
|
||||||
|
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__input.ant-input {
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 6px 2px 2px;
|
||||||
|
resize: none;
|
||||||
|
background: transparent;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__input.ant-input:focus,
|
||||||
|
.playground-chat__input.ant-input-focused {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__hints {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__send-button.ant-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||||
|
background: #0f172a;
|
||||||
|
color: #f8fafc;
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__send-button.ant-btn:hover,
|
||||||
|
.playground-chat__send-button.ant-btn:focus {
|
||||||
|
background: #111827 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__send-button--stop.ant-btn {
|
||||||
|
background: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__send-button--stop.ant-btn:hover,
|
||||||
|
.playground-chat__send-button--stop.ant-btn:focus {
|
||||||
|
background: #991b1b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message--system,
|
||||||
|
.playground-message--assistant {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message--user {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__avatar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: min(100%, 760px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__body--editing {
|
||||||
|
width: min(100%, 760px);
|
||||||
|
flex: 0 1 min(100%, 760px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__avatar-inner {
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__avatar-inner--assistant,
|
||||||
|
.playground-message__avatar-inner--system {
|
||||||
|
background: linear-gradient(135deg, #0f766e, #14b8a6) !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__avatar-inner--user {
|
||||||
|
background: linear-gradient(135deg, #0f172a, #334155) !important;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__bubble {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.98));
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message--assistant .playground-message__bubble {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(232, 244, 255, 0.95), rgba(240, 249, 255, 0.98));
|
||||||
|
border-color: rgba(56, 189, 248, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message--system .playground-message__bubble {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 250, 235, 0.98), rgba(254, 249, 195, 0.72));
|
||||||
|
border-color: rgba(245, 158, 11, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message--user .playground-message__bubble {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(15, 118, 110, 0.94), rgba(13, 148, 136, 0.96));
|
||||||
|
border-color: rgba(15, 118, 110, 0.28);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__bubble--loading {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__head .ant-tag {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__phase {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__phase .ant-typography {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__thinking {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px dashed rgba(148, 163, 184, 0.35);
|
||||||
|
background: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__thinking .ant-typography {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message--user .playground-message__head .ant-typography,
|
||||||
|
.playground-message--user .playground-message__content {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__content {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__editor-input,
|
||||||
|
.playground-message__editor-input.ant-input,
|
||||||
|
.playground-message__editor-input.ant-input-affix-wrapper,
|
||||||
|
.playground-message__editor .ant-input-textarea,
|
||||||
|
.playground-message__editor .ant-input-textarea-show-count {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__editor-input.ant-input,
|
||||||
|
.playground-message__editor .ant-input-textarea textarea.ant-input {
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
color: #0f172a;
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__editor-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__editor-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__editor-meta .ant-tag {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__editor-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__editor-buttons .ant-btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding-inline: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
min-width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__editor-buttons .ant-btn-default {
|
||||||
|
border-color: rgba(148, 163, 184, 0.3);
|
||||||
|
color: #475569;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__editor-buttons .ant-btn-default:hover,
|
||||||
|
.playground-message__editor-buttons .ant-btn-default:focus {
|
||||||
|
color: #0f172a !important;
|
||||||
|
border-color: rgba(100, 116, 139, 0.34) !important;
|
||||||
|
background: rgba(255, 255, 255, 0.92) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__editor-buttons .ant-btn-primary {
|
||||||
|
border-color: transparent;
|
||||||
|
background: linear-gradient(135deg, #0f172a, #334155);
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__editor-buttons .ant-btn-primary:hover,
|
||||||
|
.playground-message__editor-buttons .ant-btn-primary:focus {
|
||||||
|
background: linear-gradient(135deg, #020617, #1e293b) !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__markdown {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__markdown .markdown-renderer {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__markdown .markdown-renderer > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__markdown .markdown-renderer code {
|
||||||
|
background: rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message--assistant .playground-message__markdown .markdown-renderer blockquote {
|
||||||
|
background: rgba(255, 255, 255, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__detail-action {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__detail-action .ant-btn {
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-inline: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__actions .ant-btn {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-message__actions .ant-btn:hover {
|
||||||
|
color: #0f172a !important;
|
||||||
|
background: rgba(148, 163, 184, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.playground-tabs,
|
.playground-tabs,
|
||||||
.playground-tabs .ant-tabs-content-holder,
|
.playground-tabs .ant-tabs-content-holder,
|
||||||
.playground-tabs .ant-tabs-content,
|
.playground-tabs .ant-tabs-content,
|
||||||
@@ -397,6 +806,30 @@ body {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playground-preset-strip--chat {
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-preset-strip__actions .ant-tag-checkable {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-preset-strip__actions .ant-tag-checkable-checked {
|
||||||
|
background: linear-gradient(135deg, #0f766e, #14b8a6);
|
||||||
|
color: #f8fafc;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-preset-strip__actions .ant-tag-checkable:not(.ant-tag-checkable-checked) {
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
border-color: rgba(15, 118, 110, 0.16);
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
.playground-form__actions {
|
.playground-form__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -534,6 +967,11 @@ body {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playground-service-modal__body {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.playground-note.ant-alert {
|
.playground-note.ant-alert {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
@@ -621,6 +1059,21 @@ body {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playground-chat__messages::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__messages::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(148, 163, 184, 0.82);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__messages::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.playground-result__blocks-head {
|
.playground-result__blocks-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -638,6 +1091,52 @@ body {
|
|||||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playground-result__blocks-scroll--raw {
|
||||||
|
max-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-result-modal .ant-modal-content {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-result-modal__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
height: min(76vh, 760px);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-result-modal__head {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-result-modal__content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 6px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-result-modal__content::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-result-modal__content::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(148, 163, 184, 0.82);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-result-modal__content::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.playground-page__header {
|
.playground-page__header {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -652,9 +1151,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.playground-shell__sidebar {
|
.playground-shell__sidebar {
|
||||||
flex: 0 0 auto;
|
display: none;
|
||||||
min-width: 0;
|
|
||||||
max-width: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playground-result__meta,
|
.playground-result__meta,
|
||||||
@@ -662,6 +1159,23 @@ body {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playground-chat__actions {
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__hints {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat__send-button.ant-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1072,7 +1586,71 @@ body {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alerts-page__body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-page__tabs,
|
||||||
|
.alerts-page__tabs .ant-tabs-content-holder,
|
||||||
|
.alerts-page__tabs .ant-tabs-content,
|
||||||
|
.alerts-page__tabs .ant-tabs-tabpane {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-page__tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-page__tabs .ant-tabs-nav {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-page__tabs .ant-tabs-content-holder {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-page__tabs .ant-tabs-tabpane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-tab-panel {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-tab-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-tab-panel__subtitle {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
|
.alerts-tab-panel__head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.bgp-page__brief-head {
|
.bgp-page__brief-head {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -1100,6 +1678,22 @@ body {
|
|||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bgp-page__brief-facts {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgp-page__brief-fact-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgp-page__brief-fact-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.bgp-page__brief-meta .ant-descriptions-view {
|
.bgp-page__brief-meta .ant-descriptions-view {
|
||||||
background: #f7f8fa;
|
background: #f7f8fa;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -1107,6 +1701,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bgp-page__brief-modal-body {
|
.bgp-page__brief-modal-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
max-height: calc(100vh - 180px);
|
max-height: calc(100vh - 180px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
@@ -1114,6 +1710,89 @@ body {
|
|||||||
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
scrollbar-color: rgba(148, 163, 184, 0.88) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bgp-page__brief-evidence {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgp-page__brief-evidence-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-brief-drawer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-brief-drawer__loading {
|
||||||
|
min-height: 160px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-brief-fact {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-brief-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-alerts-page__body,
|
||||||
|
.bgp-alerts-page__body,
|
||||||
|
.situational-alerts-page__body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-alerts-page__stack,
|
||||||
|
.bgp-alerts-page__stack,
|
||||||
|
.situational-alerts-page__stack {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-alerts-page__table-card,
|
||||||
|
.bgp-alerts-page__table-card {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-alerts-page__table-card .ant-card-body,
|
||||||
|
.bgp-alerts-page__table-card .ant-card-body {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-alerts-page__table-region,
|
||||||
|
.bgp-alerts-page__table-region {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bgp-alerts-page__tabs,
|
||||||
|
.bgp-alerts-page__tabs .ant-tabs-content-holder,
|
||||||
|
.bgp-alerts-page__tabs .ant-tabs-content,
|
||||||
|
.bgp-alerts-page__tabs .ant-tabs-tabpane {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.bgp-page__brief-modal {
|
.bgp-page__brief-modal {
|
||||||
max-width: min(920px, calc(100vw - 32px));
|
max-width: min(920px, calc(100vw - 32px));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,221 +1,66 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Table, Tag, Card, Row, Col, Statistic, Button, Modal, Space, Descriptions } from 'antd'
|
|
||||||
import { AlertOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
|
||||||
import { useAuthStore } from '../../stores/auth'
|
|
||||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
|
||||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
|
||||||
|
|
||||||
interface Alert {
|
import { AlertOutlined, DeploymentUnitOutlined, RadarChartOutlined } from '@ant-design/icons'
|
||||||
id: number
|
import { Tabs, Typography } from 'antd'
|
||||||
severity: 'critical' | 'warning' | 'info'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
status: 'active' | 'acknowledged' | 'resolved'
|
|
||||||
datasource_name: string
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
message: string
|
import { BGPAlertsPanel } from './BGPAlerts'
|
||||||
created_at: string
|
import { SituationalAlertsPanel } from './SituationalAlerts'
|
||||||
acknowledged_at?: string
|
import { SystemAlertsPanel } from './SystemAlerts'
|
||||||
resolved_at?: string
|
|
||||||
}
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
|
const ALERT_TABS = [
|
||||||
|
{
|
||||||
|
key: 'system',
|
||||||
|
label: '系统告警',
|
||||||
|
icon: <AlertOutlined />,
|
||||||
|
children: <SystemAlertsPanel />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bgp',
|
||||||
|
label: 'BGP 告警',
|
||||||
|
icon: <DeploymentUnitOutlined />,
|
||||||
|
children: <BGPAlertsPanel />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'situational',
|
||||||
|
label: '态势告警',
|
||||||
|
icon: <RadarChartOutlined />,
|
||||||
|
children: <SituationalAlertsPanel />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
function Alerts() {
|
function Alerts() {
|
||||||
const { token } = useAuthStore()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [alerts, setAlerts] = useState<Alert[]>([])
|
const requestedTab = searchParams.get('tab') || 'system'
|
||||||
const [loading, setLoading] = useState(false)
|
const activeTab = useMemo(
|
||||||
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null)
|
() => (ALERT_TABS.some((item) => item.key === requestedTab) ? requestedTab : 'system'),
|
||||||
const [detailVisible, setDetailVisible] = useState(false)
|
[requestedTab],
|
||||||
|
|
||||||
const fetchAlerts = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/v1/alerts', {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
setAlerts(data.data || [])
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch alerts:', error)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAlerts()
|
|
||||||
}, [token])
|
|
||||||
|
|
||||||
const handleAcknowledge = async (alertId: number) => {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/v1/alerts/${alertId}/acknowledge`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
fetchAlerts()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to acknowledge alert:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResolve = async (alertId: number) => {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/v1/alerts/${alertId}/resolve`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
body: JSON.stringify({ resolution: '已处理' }),
|
|
||||||
})
|
|
||||||
fetchAlerts()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to resolve alert:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
|
|
||||||
{
|
|
||||||
title: '级别',
|
|
||||||
dataIndex: 'severity',
|
|
||||||
key: 'severity',
|
|
||||||
render: (s: string) => {
|
|
||||||
const colors: Record<string, string> = { critical: 'error', warning: 'warning', info: 'blue' }
|
|
||||||
const icons: Record<string, JSX.Element> = {
|
|
||||||
critical: <AlertOutlined />,
|
|
||||||
warning: <AlertOutlined />,
|
|
||||||
info: <InfoCircleOutlined />,
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Tag color={colors[s]} icon={icons[s]}>
|
|
||||||
{s === 'critical' ? '严重' : s === 'warning' ? '警告' : '信息'}
|
|
||||||
</Tag>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
key: 'status',
|
|
||||||
render: (s: string) => {
|
|
||||||
const colors: Record<string, string> = { active: 'red', acknowledged: 'orange', resolved: 'green' }
|
|
||||||
return (
|
|
||||||
<Tag color={colors[s]}>
|
|
||||||
{s === 'active' ? '待处理' : s === 'acknowledged' ? '已确认' : '已解决'}
|
|
||||||
</Tag>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ title: '数据源', dataIndex: 'datasource_name', key: 'datasource_name' },
|
|
||||||
{ title: '消息', dataIndex: 'message', key: 'message', ellipsis: true },
|
|
||||||
{
|
|
||||||
title: '时间',
|
|
||||||
dataIndex: 'created_at',
|
|
||||||
key: 'created_at',
|
|
||||||
render: (t: string) => formatDateTimeZhCN(t),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'action',
|
|
||||||
render: (_: unknown, record: Alert) => (
|
|
||||||
<Space>
|
|
||||||
{record.status === 'active' && (
|
|
||||||
<Button type="link" size="small" onClick={() => handleAcknowledge(record.id)}>
|
|
||||||
确认
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{record.status !== 'resolved' && (
|
|
||||||
<Button type="link" size="small" onClick={() => handleResolve(record.id)}>
|
|
||||||
解决
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="link" size="small" onClick={() => { setSelectedAlert(record); setDetailVisible(true); }}>
|
|
||||||
详情
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const stats = alerts.reduce(
|
|
||||||
(acc, alert) => {
|
|
||||||
if (alert.status === 'active') {
|
|
||||||
acc[alert.severity]++
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
},
|
|
||||||
{ critical: 0, warning: 0, info: 0 } as Record<string, number>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
<div className="page-shell alerts-page">
|
||||||
<Col span={8}>
|
<div className="page-shell__header alerts-page__header">
|
||||||
<Card>
|
<div>
|
||||||
<Statistic
|
<Title level={3} style={{ marginBottom: 4 }}>告警工作台</Title>
|
||||||
title="严重告警"
|
<Text type="secondary">
|
||||||
value={stats.critical}
|
统一查看系统告警、BGP 告警和跨模块态势告警。主工作区保持单屏,具体证据和 AI 简报在各个 Tab 内处理。
|
||||||
valueStyle={{ color: '#ff4d4f' }}
|
</Text>
|
||||||
prefix={<AlertOutlined />}
|
</div>
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Card>
|
|
||||||
<Statistic
|
|
||||||
title="警告"
|
|
||||||
value={stats.warning}
|
|
||||||
valueStyle={{ color: '#faad14' }}
|
|
||||||
prefix={<AlertOutlined />}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Card>
|
|
||||||
<Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} />
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
title="告警列表"
|
|
||||||
extra={<Button icon={<ReloadOutlined />} onClick={fetchAlerts}>刷新</Button>}
|
|
||||||
>
|
|
||||||
<div className="table-scroll-region">
|
|
||||||
<Table columns={columns} dataSource={alerts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Modal
|
<div className="page-shell__body alerts-page__body">
|
||||||
title="告警详情"
|
<Tabs
|
||||||
open={detailVisible}
|
className="alerts-page__tabs"
|
||||||
onCancel={() => setDetailVisible(false)}
|
activeKey={activeTab}
|
||||||
footer={null}
|
onChange={(key) => setSearchParams({ tab: key })}
|
||||||
width={600}
|
items={ALERT_TABS}
|
||||||
>
|
/>
|
||||||
{selectedAlert && (
|
</div>
|
||||||
<Descriptions column={1} bordered>
|
</div>
|
||||||
<Descriptions.Item label="ID">{selectedAlert.id}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="级别">
|
|
||||||
<Tag color={selectedAlert.severity === 'critical' ? 'error' : selectedAlert.severity === 'warning' ? 'warning' : 'blue'}>
|
|
||||||
{selectedAlert.severity}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="状态">
|
|
||||||
<Tag color={selectedAlert.status === 'active' ? 'red' : selectedAlert.status === 'acknowledged' ? 'orange' : 'green'}>
|
|
||||||
{selectedAlert.status}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="数据源">{selectedAlert.datasource_name}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="消息">{selectedAlert.message}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="创建时间">{formatDateTimeZhCN(selectedAlert.created_at)}</Descriptions.Item>
|
|
||||||
{selectedAlert.acknowledged_at && (
|
|
||||||
<Descriptions.Item label="确认时间">
|
|
||||||
{formatDateTimeZhCN(selectedAlert.acknowledged_at)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
)}
|
|
||||||
{selectedAlert.resolved_at && (
|
|
||||||
<Descriptions.Item label="解决时间">
|
|
||||||
{formatDateTimeZhCN(selectedAlert.resolved_at)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
)}
|
|
||||||
</Descriptions>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
264
frontend/src/pages/Alerts/BGPAlerts.tsx
Normal file
264
frontend/src/pages/Alerts/BGPAlerts.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { ReloadOutlined, RobotOutlined } from '@ant-design/icons'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Descriptions,
|
||||||
|
Modal,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Statistic,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
type TableColumnsType,
|
||||||
|
} from 'antd'
|
||||||
|
|
||||||
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
import type { BGPAnomaly, BGPBriefRecord, BGPIncident } from '../../services/situational-awareness'
|
||||||
|
import { getSituationalAwarenessGateway } from '../../services/situational-awareness'
|
||||||
|
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
const gateway = getSituationalAwarenessGateway()
|
||||||
|
|
||||||
|
function severityColor(severity: string) {
|
||||||
|
if (severity === 'critical') return 'red'
|
||||||
|
if (severity === 'high') return 'orange'
|
||||||
|
if (severity === 'medium') return 'gold'
|
||||||
|
return 'blue'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BGPAlertsPanel() {
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [incidents, setIncidents] = useState<BGPIncident[]>([])
|
||||||
|
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
|
||||||
|
const [briefLoading, setBriefLoading] = useState(false)
|
||||||
|
const [briefModalOpen, setBriefModalOpen] = useState(false)
|
||||||
|
const [brief, setBrief] = useState<BGPBriefRecord | null>(null)
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [incidentRows, anomalyRows] = await Promise.all([
|
||||||
|
gateway.getBGPIncidents(50),
|
||||||
|
gateway.getBGPAnomalies(80),
|
||||||
|
])
|
||||||
|
setIncidents(incidentRows)
|
||||||
|
setAnomalies(anomalyRows)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load BGP alerts:', error)
|
||||||
|
messageApi.error('BGP 告警加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const summary = useMemo(
|
||||||
|
() => ({
|
||||||
|
activeIncidents: incidents.filter((item) => item.status === 'active').length,
|
||||||
|
criticalIncidents: incidents.filter((item) => item.severity === 'critical').length,
|
||||||
|
activeAnomalies: anomalies.filter((item) => item.status === 'active').length,
|
||||||
|
highRiskAnomalies: anomalies.filter((item) => ['critical', 'high'].includes(item.severity)).length,
|
||||||
|
}),
|
||||||
|
[anomalies, incidents],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleGenerateBrief = async () => {
|
||||||
|
setBriefModalOpen(true)
|
||||||
|
setBriefLoading(true)
|
||||||
|
try {
|
||||||
|
const record = await gateway.generateBGPBrief()
|
||||||
|
setBrief(record)
|
||||||
|
messageApi.success('BGP AI 简报已生成')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate BGP brief:', error)
|
||||||
|
messageApi.error('BGP AI 简报生成失败')
|
||||||
|
} finally {
|
||||||
|
setBriefLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const incidentColumns: TableColumnsType<BGPIncident> = [
|
||||||
|
{
|
||||||
|
title: '开始时间',
|
||||||
|
dataIndex: 'started_at',
|
||||||
|
width: 180,
|
||||||
|
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||||
|
},
|
||||||
|
{ title: '类型', dataIndex: 'incident_type', width: 180 },
|
||||||
|
{
|
||||||
|
title: '严重度',
|
||||||
|
dataIndex: 'severity',
|
||||||
|
width: 120,
|
||||||
|
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 120,
|
||||||
|
render: (value: string) => <Tag color={value === 'active' ? 'red' : 'blue'}>{value}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '影响前缀',
|
||||||
|
dataIndex: 'affected_prefixes',
|
||||||
|
width: 220,
|
||||||
|
render: (value: string[]) => (value && value.length > 0 ? value.join(', ') : '-'),
|
||||||
|
},
|
||||||
|
{ title: '摘要', dataIndex: 'summary', width: 320 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const anomalyColumns: TableColumnsType<BGPAnomaly> = [
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||||
|
},
|
||||||
|
{ title: '类型', dataIndex: 'anomaly_type', width: 180 },
|
||||||
|
{
|
||||||
|
title: '严重度',
|
||||||
|
dataIndex: 'severity',
|
||||||
|
width: 120,
|
||||||
|
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 120,
|
||||||
|
render: (value: string) => <Tag color={value === 'active' ? 'red' : 'blue'}>{value}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '前缀',
|
||||||
|
dataIndex: 'prefix',
|
||||||
|
width: 200,
|
||||||
|
render: (value: string | null) => value || '-',
|
||||||
|
},
|
||||||
|
{ title: '摘要', dataIndex: 'summary', width: 320 },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="alerts-tab-panel bgp-alerts-page">
|
||||||
|
{contextHolder}
|
||||||
|
<Space className="bgp-alerts-page__stack" direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<div className="alerts-tab-panel__head">
|
||||||
|
<div>
|
||||||
|
<Text strong>BGP 告警</Text>
|
||||||
|
<div className="alerts-tab-panel__subtitle">把 BGP incidents 与 anomalies 当作告警工作台来快速筛查控制平面风险。</div>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" icon={<RobotOutlined />} loading={briefLoading} onClick={() => void handleGenerateBrief()}>
|
||||||
|
生成 BGP AI 简报
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert type="info" showIcon message="这里聚焦 BGP 风险信号本身,不等同于系统平台运行告警。" />
|
||||||
|
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card><Statistic title="活跃事件" value={summary.activeIncidents} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card><Statistic title="严重事件" value={summary.criticalIncidents} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card><Statistic title="活跃异常" value={summary.activeAnomalies} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card><Statistic title="高风险异常" value={summary.highRiskAnomalies} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card className="bgp-alerts-page__table-card">
|
||||||
|
<Tabs
|
||||||
|
className="bgp-alerts-page__tabs"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'incidents',
|
||||||
|
label: 'BGP 事件',
|
||||||
|
children: (
|
||||||
|
<div className="table-scroll-region bgp-alerts-page__table-region">
|
||||||
|
<Table<BGPIncident>
|
||||||
|
columns={incidentColumns}
|
||||||
|
dataSource={incidents}
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
rowKey="id"
|
||||||
|
scroll={{ x: 1200, y: 480 }}
|
||||||
|
tableLayout="fixed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'anomalies',
|
||||||
|
label: 'BGP 异常',
|
||||||
|
children: (
|
||||||
|
<div className="table-scroll-region bgp-alerts-page__table-region">
|
||||||
|
<Table<BGPAnomaly>
|
||||||
|
columns={anomalyColumns}
|
||||||
|
dataSource={anomalies}
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
rowKey="id"
|
||||||
|
scroll={{ x: 1100, y: 480 }}
|
||||||
|
tableLayout="fixed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="BGP AI 简报"
|
||||||
|
open={briefModalOpen}
|
||||||
|
onCancel={() => setBriefModalOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={920}
|
||||||
|
className="bgp-page__brief-modal"
|
||||||
|
style={{ top: 24 }}
|
||||||
|
styles={{ body: { paddingTop: 12 } }}
|
||||||
|
>
|
||||||
|
{briefLoading ? (
|
||||||
|
<div className="bgp-page__brief-loading">
|
||||||
|
<Spin tip="正在生成 BGP AI 简报..." />
|
||||||
|
</div>
|
||||||
|
) : brief ? (
|
||||||
|
<div className="bgp-page__brief-modal-body">
|
||||||
|
<Descriptions size="small" column={3} className="bgp-page__brief-meta">
|
||||||
|
<Descriptions.Item label="Provider">{brief.provider || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="模型">{brief.model || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="生成时间">{formatDateTimeZhCN(brief.generated_at)}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<Typography.Paragraph className="alerts-brief-content">{brief.content_markdown}</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">当前没有可查看的 BGP 简报。</Text>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BGPAlertsPanel
|
||||||
202
frontend/src/pages/Alerts/SituationalAlerts.tsx
Normal file
202
frontend/src/pages/Alerts/SituationalAlerts.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { DeploymentUnitOutlined, ReloadOutlined, RobotOutlined, WarningOutlined } from '@ant-design/icons'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Statistic,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
import type { BGPSummarySnapshot } from '../../services/situational-awareness'
|
||||||
|
import {
|
||||||
|
getSituationalAwarenessGateway,
|
||||||
|
type SituationalAlertBriefResponse,
|
||||||
|
} from '../../services/situational-awareness'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
||||||
|
const gateway = getSituationalAwarenessGateway()
|
||||||
|
|
||||||
|
interface AlertStatsResponse {
|
||||||
|
critical: number
|
||||||
|
warning: number
|
||||||
|
info: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SituationalAlertsPanel() {
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
const [systemStats, setSystemStats] = useState<AlertStatsResponse | null>(null)
|
||||||
|
const [bgpSummary, setBgpSummary] = useState<BGPSummarySnapshot | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [briefOpen, setBriefOpen] = useState(false)
|
||||||
|
const [briefLoading, setBriefLoading] = useState(false)
|
||||||
|
const [briefError, setBriefError] = useState<string | null>(null)
|
||||||
|
const [briefResult, setBriefResult] = useState<SituationalAlertBriefResponse | null>(null)
|
||||||
|
|
||||||
|
const loadOverview = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [alertStatsResponse, bgpSummaryResponse] = await Promise.all([
|
||||||
|
axios.get<AlertStatsResponse>(`${API_BASE_URL}/alerts/stats`),
|
||||||
|
gateway.getBGPSummary(),
|
||||||
|
])
|
||||||
|
setSystemStats(alertStatsResponse.data)
|
||||||
|
setBgpSummary(bgpSummaryResponse)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load situational alerts overview:', error)
|
||||||
|
messageApi.error('态势告警概览加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadOverview()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const summary = useMemo(
|
||||||
|
() => ({
|
||||||
|
activeSystemAlerts: (systemStats?.critical || 0) + (systemStats?.warning || 0) + (systemStats?.info || 0),
|
||||||
|
criticalSystemAlerts: systemStats?.critical || 0,
|
||||||
|
activeBGPIncidents: bgpSummary?.incidentSummary?.by_status?.active || 0,
|
||||||
|
criticalBGPIncidents: bgpSummary?.incidentSummary?.by_severity?.critical || 0,
|
||||||
|
}),
|
||||||
|
[bgpSummary, systemStats],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleGenerateBrief = async () => {
|
||||||
|
setBriefOpen(true)
|
||||||
|
setBriefLoading(true)
|
||||||
|
setBriefError(null)
|
||||||
|
try {
|
||||||
|
const response = await axios.post<SituationalAlertBriefResponse>(`${API_BASE_URL}/ai/situational-alerts/brief`, {})
|
||||||
|
setBriefResult(response.data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate situational alert brief:', error)
|
||||||
|
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
|
||||||
|
setBriefError(detail || '生成态势告警 AI 简报失败')
|
||||||
|
messageApi.error('生成态势告警 AI 简报失败')
|
||||||
|
} finally {
|
||||||
|
setBriefLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="alerts-tab-panel situational-alerts-page">
|
||||||
|
{contextHolder}
|
||||||
|
<Space className="situational-alerts-page__stack" direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<div className="alerts-tab-panel__head">
|
||||||
|
<div>
|
||||||
|
<Text strong>态势告警</Text>
|
||||||
|
<div className="alerts-tab-panel__subtitle">把系统告警与 BGP 风险放在同一视角下综合研判,适合做值班总览和跨模块优先级排序。</div>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" icon={<RobotOutlined />} loading={briefLoading} onClick={() => void handleGenerateBrief()}>
|
||||||
|
生成态势 AI 简报
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => void loadOverview()}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="态势告警不是单一模块列表,而是把系统告警与 BGP 风险综合成一份值班研判入口。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card><Statistic title="活跃系统告警" value={summary.activeSystemAlerts} prefix={<WarningOutlined />} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card><Statistic title="严重系统告警" value={summary.criticalSystemAlerts} valueStyle={{ color: '#ff4d4f' }} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card><Statistic title="活跃 BGP 事件" value={summary.activeBGPIncidents} prefix={<DeploymentUnitOutlined />} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card><Statistic title="严重 BGP 事件" value={summary.criticalBGPIncidents} valueStyle={{ color: '#fa8c16' }} /></Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title="系统告警侧">
|
||||||
|
<Descriptions size="small" column={1}>
|
||||||
|
<Descriptions.Item label="严重">{String(systemStats?.critical ?? '-')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="警告">{String(systemStats?.warning ?? '-')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="信息">{String(systemStats?.info ?? '-')}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title="BGP 风险侧">
|
||||||
|
<Descriptions size="small" column={1}>
|
||||||
|
<Descriptions.Item label="活跃事件">{String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="严重事件">{String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="活跃观测站">{String(bgpSummary?.collectorSummary?.active_collectors ?? '-')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="近24h事件">{String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Drawer title="态势告警 AI 简报" placement="right" width={560} onClose={() => setBriefOpen(false)} open={briefOpen}>
|
||||||
|
<div className="alerts-brief-drawer">
|
||||||
|
{briefLoading ? (
|
||||||
|
<div className="alerts-brief-drawer__loading">
|
||||||
|
<Spin tip="正在生成态势告警 AI 简报..." />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!briefLoading && briefError ? (
|
||||||
|
<Alert type="error" showIcon message="态势告警 AI 简报生成失败" description={briefError} />
|
||||||
|
) : null}
|
||||||
|
{!briefLoading && briefResult ? (
|
||||||
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
<Card size="small">
|
||||||
|
<Descriptions size="small" column={1}>
|
||||||
|
<Descriptions.Item label="目标">{briefResult.objective}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="模型">{`${briefResult.provider} / ${briefResult.model}`}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="活跃系统告警">{String(briefResult.context.active_system_alerts ?? '-')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="活跃 BGP 事件">{String(briefResult.context.active_bgp_incidents ?? '-')}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
<Card size="small" title="事实输入">
|
||||||
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
|
{briefResult.facts.map((fact, index) => (
|
||||||
|
<div key={`${index}-${fact}`} className="alerts-brief-fact">
|
||||||
|
<Text strong>{index + 1}.</Text>
|
||||||
|
<Text>{fact}</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
<Card size="small" title="AI 简报">
|
||||||
|
<Typography.Paragraph className="alerts-brief-content">{briefResult.content}</Typography.Paragraph>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SituationalAlertsPanel
|
||||||
303
frontend/src/pages/Alerts/SystemAlerts.tsx
Normal file
303
frontend/src/pages/Alerts/SystemAlerts.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { AlertOutlined, InfoCircleOutlined, ReloadOutlined, RobotOutlined } from '@ant-design/icons'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Modal,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Statistic,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
type TableColumnsType,
|
||||||
|
} from 'antd'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
import type { AlertBriefResponse, AlertRecord } from '../../services/situational-awareness'
|
||||||
|
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
||||||
|
|
||||||
|
function renderAlertSeverityTag(value: AlertRecord['severity']) {
|
||||||
|
const colorMap = { critical: 'error', warning: 'warning', info: 'blue' }
|
||||||
|
const labelMap = { critical: '严重', warning: '警告', info: '信息' }
|
||||||
|
const iconMap = {
|
||||||
|
critical: <AlertOutlined />,
|
||||||
|
warning: <AlertOutlined />,
|
||||||
|
info: <InfoCircleOutlined />,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag color={colorMap[value]} icon={iconMap[value]}>
|
||||||
|
{labelMap[value]}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlertStatusTag(value: AlertRecord['status']) {
|
||||||
|
const colorMap = { active: 'red', acknowledged: 'orange', resolved: 'green' }
|
||||||
|
const labelMap = { active: '待处理', acknowledged: '已确认', resolved: '已解决' }
|
||||||
|
return <Tag color={colorMap[value]}>{labelMap[value]}</Tag>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemAlertsPanel() {
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
const [alerts, setAlerts] = useState<AlertRecord[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [selectedAlert, setSelectedAlert] = useState<AlertRecord | null>(null)
|
||||||
|
const [detailVisible, setDetailVisible] = useState(false)
|
||||||
|
const [briefOpen, setBriefOpen] = useState(false)
|
||||||
|
const [briefLoading, setBriefLoading] = useState(false)
|
||||||
|
const [briefError, setBriefError] = useState<string | null>(null)
|
||||||
|
const [briefResult, setBriefResult] = useState<AlertBriefResponse | null>(null)
|
||||||
|
|
||||||
|
const fetchAlerts = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await axios.get<{ data: AlertRecord[] }>(`${API_BASE_URL}/alerts`)
|
||||||
|
setAlerts(response.data.data || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch system alerts:', error)
|
||||||
|
messageApi.error('系统告警加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchAlerts()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stats = useMemo(
|
||||||
|
() =>
|
||||||
|
alerts.reduce(
|
||||||
|
(accumulator, item) => {
|
||||||
|
if (item.status === 'active') {
|
||||||
|
accumulator[item.severity] += 1
|
||||||
|
}
|
||||||
|
return accumulator
|
||||||
|
},
|
||||||
|
{ critical: 0, warning: 0, info: 0 } as Record<AlertRecord['severity'], number>,
|
||||||
|
),
|
||||||
|
[alerts],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAcknowledge = async (alertId: number) => {
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_BASE_URL}/alerts/${alertId}/acknowledge`)
|
||||||
|
messageApi.success('告警已确认')
|
||||||
|
await fetchAlerts()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to acknowledge alert:', error)
|
||||||
|
messageApi.error('确认告警失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResolve = async (alertId: number) => {
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_BASE_URL}/alerts/${alertId}/resolve`, { resolution: '已处理' })
|
||||||
|
messageApi.success('告警已解决')
|
||||||
|
await fetchAlerts()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to resolve alert:', error)
|
||||||
|
messageApi.error('解决告警失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGenerateBrief = async () => {
|
||||||
|
setBriefOpen(true)
|
||||||
|
setBriefLoading(true)
|
||||||
|
setBriefError(null)
|
||||||
|
try {
|
||||||
|
const response = await axios.post<AlertBriefResponse>(`${API_BASE_URL}/ai/alerts/brief`, {})
|
||||||
|
setBriefResult(response.data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate system alert brief:', error)
|
||||||
|
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
|
||||||
|
setBriefError(detail || '生成系统告警 AI 简报失败')
|
||||||
|
messageApi.error('生成系统告警 AI 简报失败')
|
||||||
|
} finally {
|
||||||
|
setBriefLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<AlertRecord> = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 72 },
|
||||||
|
{
|
||||||
|
title: '级别',
|
||||||
|
dataIndex: 'severity',
|
||||||
|
width: 108,
|
||||||
|
render: (value: AlertRecord['severity']) => renderAlertSeverityTag(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 108,
|
||||||
|
render: (value: AlertRecord['status']) => renderAlertStatusTag(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数据源',
|
||||||
|
dataIndex: 'datasource_name',
|
||||||
|
width: 180,
|
||||||
|
render: (value: string | null) => value || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '消息',
|
||||||
|
dataIndex: 'message',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (value: string) => formatDateTimeZhCN(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space size={4}>
|
||||||
|
{record.status === 'active' ? (
|
||||||
|
<Button type="link" size="small" onClick={() => void handleAcknowledge(record.id)}>
|
||||||
|
确认
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{record.status !== 'resolved' ? (
|
||||||
|
<Button type="link" size="small" onClick={() => void handleResolve(record.id)}>
|
||||||
|
解决
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedAlert(record)
|
||||||
|
setDetailVisible(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="alerts-tab-panel system-alerts-page">
|
||||||
|
{contextHolder}
|
||||||
|
<Space className="system-alerts-page__stack" direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<div className="alerts-tab-panel__head">
|
||||||
|
<div>
|
||||||
|
<Text strong>系统告警</Text>
|
||||||
|
<div className="alerts-tab-panel__subtitle">聚焦平台运行、采集链路和系统内部异常。</div>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" icon={<RobotOutlined />} loading={briefLoading} onClick={() => void handleGenerateBrief()}>
|
||||||
|
生成 AI 简报
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => void fetchAlerts()}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert type="info" showIcon message="这里展示的是平台与采集链路告警,不等同于 BGP 态势风险本身。" />
|
||||||
|
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Card><Statistic title="严重告警" value={stats.critical} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Card><Statistic title="警告" value={stats.warning} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} /></Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Card><Statistic title="信息" value={stats.info} valueStyle={{ color: '#1890ff' }} prefix={<InfoCircleOutlined />} /></Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card className="system-alerts-page__table-card" title="系统告警列表">
|
||||||
|
<div className="table-scroll-region system-alerts-page__table-region">
|
||||||
|
<Table<AlertRecord>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={alerts}
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 10 }}
|
||||||
|
rowKey="id"
|
||||||
|
scroll={{ x: 1100, y: 480 }}
|
||||||
|
tableLayout="fixed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Modal title="告警详情" open={detailVisible} onCancel={() => setDetailVisible(false)} footer={null} width={640}>
|
||||||
|
{selectedAlert ? (
|
||||||
|
<Descriptions bordered column={1}>
|
||||||
|
<Descriptions.Item label="ID">{selectedAlert.id}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="级别">{renderAlertSeverityTag(selectedAlert.severity)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">{renderAlertStatusTag(selectedAlert.status)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="数据源">{selectedAlert.datasource_name || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="消息">{selectedAlert.message}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间">{formatDateTimeZhCN(selectedAlert.created_at)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="确认时间">{formatDateTimeZhCN(selectedAlert.acknowledged_at || null)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="解决时间">{formatDateTimeZhCN(selectedAlert.resolved_at || null)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="处理说明">{selectedAlert.resolution_notes || '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Drawer title="系统告警 AI 简报" placement="right" width={520} onClose={() => setBriefOpen(false)} open={briefOpen}>
|
||||||
|
<div className="alerts-brief-drawer">
|
||||||
|
{briefLoading ? (
|
||||||
|
<div className="alerts-brief-drawer__loading">
|
||||||
|
<Spin tip="正在汇总系统告警事实并生成简报..." />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!briefLoading && briefError ? (
|
||||||
|
<Alert type="error" showIcon message="系统告警 AI 简报生成失败" description={briefError} />
|
||||||
|
) : null}
|
||||||
|
{!briefLoading && briefResult ? (
|
||||||
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||||
|
<Card size="small">
|
||||||
|
<Descriptions size="small" column={1}>
|
||||||
|
<Descriptions.Item label="目标">{briefResult.objective}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="模型">{`${briefResult.provider} / ${briefResult.model}`}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="待处理告警">{String(briefResult.context.active_alerts ?? '-')}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
<Card size="small" title="事实输入">
|
||||||
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
|
{briefResult.facts.map((fact, index) => (
|
||||||
|
<div key={`${index}-${fact}`} className="alerts-brief-fact">
|
||||||
|
<Text strong>{index + 1}.</Text>
|
||||||
|
<Text>{fact}</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
<Card size="small" title="AI 简报">
|
||||||
|
<Typography.Paragraph className="alerts-brief-content">{briefResult.content}</Typography.Paragraph>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SystemAlertsPanel
|
||||||
@@ -66,6 +66,19 @@ function sortBriefRecords<T extends BGPBriefRecordSummary>(records: T[]) {
|
|||||||
return [...records].sort((left, right) => right.generated_at.localeCompare(left.generated_at))
|
return [...records].sort((left, right) => right.generated_at.localeCompare(left.generated_at))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderBriefContextValue(value: unknown) {
|
||||||
|
if (value === null || value === undefined) return '-'
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0 ? JSON.stringify(value) : '-'
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const entries = Object.entries(value as Record<string, unknown>)
|
||||||
|
if (entries.length === 0) return '-'
|
||||||
|
return entries.map(([key, count]) => `${key}: ${String(count)}`).join(',')
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
function renderCollectorLocation(_: unknown, record: BGPCollectorCoverage) {
|
function renderCollectorLocation(_: unknown, record: BGPCollectorCoverage) {
|
||||||
return [record.city, record.country].filter(Boolean).join(', ') || '-'
|
return [record.city, record.country].filter(Boolean).join(', ') || '-'
|
||||||
}
|
}
|
||||||
@@ -574,7 +587,23 @@ function BGP() {
|
|||||||
<Descriptions.Item label="生成时间" span={compactViewport ? 1 : 3}>
|
<Descriptions.Item label="生成时间" span={compactViewport ? 1 : 3}>
|
||||||
{formatDateTimeZhCN(brief.generated_at)}
|
{formatDateTimeZhCN(brief.generated_at)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="事实条目">{brief.facts.length}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Incident 总数">{renderBriefContextValue(brief.context.incident_total)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="活跃观测站">{renderBriefContextValue(brief.context.active_collectors)}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
{brief.facts.length > 0 ? (
|
||||||
|
<div className="bgp-page__brief-facts">
|
||||||
|
<Text strong>事实快照</Text>
|
||||||
|
<div className="bgp-page__brief-fact-list">
|
||||||
|
{brief.facts.slice(0, 3).map((fact, index) => (
|
||||||
|
<div key={`${index}-${fact}`} className="bgp-page__brief-fact-item">
|
||||||
|
<Text strong>{index + 1}.</Text>
|
||||||
|
<Text>{fact}</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bgp-page__brief-empty">
|
<div className="bgp-page__brief-empty">
|
||||||
@@ -727,6 +756,34 @@ function BGP() {
|
|||||||
>
|
>
|
||||||
{brief ? (
|
{brief ? (
|
||||||
<div className="bgp-page__brief-modal-body">
|
<div className="bgp-page__brief-modal-body">
|
||||||
|
{(brief.facts.length > 0 || Object.keys(brief.context || {}).length > 0) ? (
|
||||||
|
<div className="bgp-page__brief-evidence">
|
||||||
|
{brief.facts.length > 0 ? (
|
||||||
|
<Card size="small" title="事实输入快照" className="bgp-page__brief-evidence-card">
|
||||||
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
|
{brief.facts.map((fact, index) => (
|
||||||
|
<div key={`${index}-${fact}`} className="bgp-page__brief-fact-item">
|
||||||
|
<Text strong>{index + 1}.</Text>
|
||||||
|
<Text>{fact}</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{Object.keys(brief.context || {}).length > 0 ? (
|
||||||
|
<Card size="small" title="结构化上下文" className="bgp-page__brief-evidence-card">
|
||||||
|
<Descriptions size="small" column={1}>
|
||||||
|
{Object.entries(brief.context).map(([key, value]) => (
|
||||||
|
<Descriptions.Item key={key} label={key}>
|
||||||
|
{renderBriefContextValue(value)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
))}
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<MarkdownRenderer markdown={brief.content_markdown} />
|
<MarkdownRenderer markdown={brief.content_markdown} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
|||||||
import type { SituationalAwarenessGateway } from './port'
|
import type { SituationalAwarenessGateway } from './port'
|
||||||
import { HttpSituationalAwarenessGateway } from './http-gateway'
|
import { HttpSituationalAwarenessGateway } from './http-gateway'
|
||||||
import { MockSituationalAwarenessGateway } from './mock-gateway'
|
|
||||||
|
|
||||||
export * from './types'
|
export * from './types'
|
||||||
export type { SituationalAwarenessGateway } from './port'
|
export type { SituationalAwarenessGateway } from './port'
|
||||||
@@ -8,10 +7,6 @@ export type { SituationalAwarenessGateway } from './port'
|
|||||||
let singleton: SituationalAwarenessGateway | null = null
|
let singleton: SituationalAwarenessGateway | null = null
|
||||||
|
|
||||||
export function createSituationalAwarenessGateway(): SituationalAwarenessGateway {
|
export function createSituationalAwarenessGateway(): SituationalAwarenessGateway {
|
||||||
const provider = (import.meta as any).env?.VITE_SA_GATEWAY || 'http'
|
|
||||||
if (provider === 'mock') {
|
|
||||||
return new MockSituationalAwarenessGateway()
|
|
||||||
}
|
|
||||||
return new HttpSituationalAwarenessGateway()
|
return new HttpSituationalAwarenessGateway()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import type { SituationalAwarenessGateway } from './port'
|
|
||||||
import type {
|
|
||||||
BGPBriefRecord,
|
|
||||||
BGPBriefRecordSummary,
|
|
||||||
BGPOverviewOptions,
|
|
||||||
BGPOverviewSnapshot,
|
|
||||||
BGPSummarySnapshot,
|
|
||||||
} 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 {
|
|
||||||
private readonly brief: BGPBriefRecord = {
|
|
||||||
id: 'mock-bgp-brief-001',
|
|
||||||
title: 'BGP AI 简报',
|
|
||||||
provider: 'mock',
|
|
||||||
model: 'mock-brief',
|
|
||||||
request_id: 'mock-bgp-brief',
|
|
||||||
generated_at: '2026-04-09T12:00:00+08:00',
|
|
||||||
content_markdown: [
|
|
||||||
'# BGP 态势简报',
|
|
||||||
'',
|
|
||||||
'## 当前判断',
|
|
||||||
'',
|
|
||||||
'- 当前 BGP 态势以高严重度事件为主。',
|
|
||||||
'- 建议优先核查活跃 incidents 涉及的受影响前缀与重点观测站。',
|
|
||||||
'',
|
|
||||||
'## 值班建议',
|
|
||||||
'',
|
|
||||||
'1. 先确认高严重度 incident 是否持续活跃。',
|
|
||||||
'2. 对照重点 collector 的近 24h 波动,避免将控制平面噪声误判为真实业务中断。',
|
|
||||||
].join('\n'),
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBGPOverview(_options: BGPOverviewOptions = {}): Promise<BGPOverviewSnapshot> {
|
|
||||||
return EMPTY_SNAPSHOT
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBGPSummary(): Promise<BGPSummarySnapshot> {
|
|
||||||
return {
|
|
||||||
incidentSummary: EMPTY_SNAPSHOT.incidentSummary,
|
|
||||||
eventSummary: EMPTY_SNAPSHOT.eventSummary,
|
|
||||||
collectorSummary: EMPTY_SNAPSHOT.collectorSummary,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBGPCollectors() {
|
|
||||||
return EMPTY_SNAPSHOT.collectors
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBGPIncidents() {
|
|
||||||
return EMPTY_SNAPSHOT.incidents
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBGPAnomalies() {
|
|
||||||
return EMPTY_SNAPSHOT.anomalies
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBGPEvents() {
|
|
||||||
return EMPTY_SNAPSHOT.events
|
|
||||||
}
|
|
||||||
|
|
||||||
async generateBGPBrief(): Promise<BGPBriefRecord> {
|
|
||||||
return this.brief
|
|
||||||
}
|
|
||||||
|
|
||||||
async listBGPBriefs(): Promise<BGPBriefRecordSummary[]> {
|
|
||||||
return [this.brief]
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBGPBrief(_briefId: string): Promise<BGPBriefRecord> {
|
|
||||||
return this.brief
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLatestBGPBrief(): Promise<BGPBriefRecord | null> {
|
|
||||||
return this.brief
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -144,4 +144,47 @@ export interface BGPBriefRecordSummary {
|
|||||||
|
|
||||||
export interface BGPBriefRecord extends BGPBriefRecordSummary {
|
export interface BGPBriefRecord extends BGPBriefRecordSummary {
|
||||||
content_markdown: string
|
content_markdown: string
|
||||||
|
facts: string[]
|
||||||
|
context: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertRecord {
|
||||||
|
id: number
|
||||||
|
severity: 'critical' | 'warning' | 'info'
|
||||||
|
status: 'active' | 'acknowledged' | 'resolved'
|
||||||
|
datasource_name: string | null
|
||||||
|
message: string
|
||||||
|
created_at: string
|
||||||
|
acknowledged_at?: string | null
|
||||||
|
resolved_at?: string | null
|
||||||
|
alert_metadata?: string | null
|
||||||
|
resolution_notes?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertBriefResponse {
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
content: string
|
||||||
|
content_blocks: AIContentBlock[]
|
||||||
|
text_blocks: string[]
|
||||||
|
thinking_blocks: string[]
|
||||||
|
raw_response: Record<string, unknown>
|
||||||
|
title: string
|
||||||
|
objective: string
|
||||||
|
facts: string[]
|
||||||
|
context: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SituationalAlertBriefResponse {
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
content: string
|
||||||
|
content_blocks: AIContentBlock[]
|
||||||
|
text_blocks: string[]
|
||||||
|
thinking_blocks: string[]
|
||||||
|
raw_response: Record<string, unknown>
|
||||||
|
title: string
|
||||||
|
objective: string
|
||||||
|
facts: string[]
|
||||||
|
context: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user