From 89a71e6f294e4060abc1621242e6eb98fac32017 Mon Sep 17 00:00:00 2001 From: linkong Date: Fri, 10 Apr 2026 15:57:34 +0800 Subject: [PATCH] feat: ship persistent ai playground and alerts foundation --- README.md | 1 + backend/app/api/v1/ai.py | 184 ++- backend/app/api/v1/alerts.py | 9 +- backend/app/api/v1/dashboard.py | 2 +- backend/app/db/session.py | 2 + backend/app/models/__init__.py | 2 + backend/app/models/playground_message.py | 40 + backend/app/models/playground_session.py | 27 + backend/app/schemas/ai.py | 114 ++ backend/app/schemas/alert.py | 5 + backend/app/services/alert_ai_brief.py | 103 ++ backend/app/services/bgp_ai_brief.py | 138 +- backend/app/services/bgp_ai_brief_store.py | 15 +- backend/app/services/bgp_enrichment.py | 7 + .../app/services/playground_chat_service.py | 678 +++++++++ .../app/services/playground_session_store.py | 72 + .../services/situational_alert_ai_brief.py | 174 +++ backend/tests/test_api.py | 296 +++- docs/situational-awareness-foundation-plan.md | 309 +++++ frontend/src/App.tsx | 9 + .../src/components/AppLayout/AppLayout.tsx | 84 +- frontend/src/index.css | 685 ++++++++- frontend/src/pages/Alerts/Alerts.tsx | 263 +--- frontend/src/pages/Alerts/BGPAlerts.tsx | 264 ++++ .../src/pages/Alerts/SituationalAlerts.tsx | 202 +++ frontend/src/pages/Alerts/SystemAlerts.tsx | 303 ++++ frontend/src/pages/BGP/BGP.tsx | 57 + frontend/src/pages/Playground/Playground.tsx | 1227 ++++++++++++----- .../services/situational-awareness/index.ts | 5 - .../situational-awareness/mock-gateway.ts | 103 -- .../services/situational-awareness/types.ts | 43 + 31 files changed, 4754 insertions(+), 669 deletions(-) create mode 100644 backend/app/models/playground_message.py create mode 100644 backend/app/models/playground_session.py create mode 100644 backend/app/schemas/alert.py create mode 100644 backend/app/services/alert_ai_brief.py create mode 100644 backend/app/services/playground_chat_service.py create mode 100644 backend/app/services/playground_session_store.py create mode 100644 backend/app/services/situational_alert_ai_brief.py create mode 100644 docs/situational-awareness-foundation-plan.md create mode 100644 frontend/src/pages/Alerts/BGPAlerts.tsx create mode 100644 frontend/src/pages/Alerts/SituationalAlerts.tsx create mode 100644 frontend/src/pages/Alerts/SystemAlerts.tsx delete mode 100644 frontend/src/services/situational-awareness/mock-gateway.ts diff --git a/README.md b/README.md index 303a67d9..6ce6ab59 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,7 @@ AI_PROVIDER_SERVICE_TOKEN=change_me - [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/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) ## 前端页面布局规范 diff --git a/backend/app/api/v1/ai.py b/backend/app/api/v1/ai.py index 364db973..2900ceb7 100644 --- a/backend/app/api/v1/ai.py +++ b/backend/app/api/v1/ai.py @@ -8,12 +8,25 @@ from app.db.session import get_db from app.models.user import User from app.schemas.ai import ( AIProviderStatusResponse, + AlertBriefRequest, + AlertBriefResponse, BGPBriefRequest, BGPBriefRecordResponse, BGPBriefRecordSummary, + PlaygroundMessageActionResponse, + PlaygroundMessageCreateRequest, + PlaygroundMessageEditRequest, + PlaygroundMessageResendRequest, + PlaygroundMessageStopRequest, + PlaygroundSessionResponse, + PlaygroundSessionUpsertRequest, + PlaygroundThreadResponse, + SituationalAlertBriefRequest, + SituationalAlertBriefResponse, SituationalAnalysisRequest, 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.bgp_ai_brief import build_bgp_brief_request from app.services.bgp_ai_brief_store import ( @@ -22,6 +35,18 @@ from app.services.bgp_ai_brief_store import ( list_bgp_brief_records, 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() @@ -51,6 +76,101 @@ async def analyze_situational_awareness( 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]) async def list_saved_bgp_briefs( 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()) response.headers["X-Request-ID"] = request_id - brief_request = await build_bgp_brief_request( + brief_request, facts, context = await build_bgp_brief_request( db, incident_limit=payload.incident_limit, anomaly_limit=payload.anomaly_limit, @@ -98,4 +218,64 @@ async def analyze_bgp_brief( brief_request.thinking = payload.thinking 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, + ) diff --git a/backend/app/api/v1/alerts.py b/backend/app/api/v1/alerts.py index c53c046c..f780c203 100644 --- a/backend/app/api/v1/alerts.py +++ b/backend/app/api/v1/alerts.py @@ -1,7 +1,7 @@ from datetime import UTC, datetime from typing import Optional -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select, func, case 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.core.security import get_current_user from app.models.alert import Alert, AlertSeverity, AlertStatus +from app.schemas.alert import AlertResolutionRequest router = APIRouter() @@ -77,7 +78,7 @@ async def acknowledge_alert( @router.post("/{alert_id}/resolve") async def resolve_alert( alert_id: int, - resolution: str, + payload: AlertResolutionRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -85,12 +86,12 @@ async def resolve_alert( alert = result.scalar_one_or_none() if not alert: - return {"error": "Alert not found"} + raise HTTPException(status_code=404, detail="Alert not found") alert.status = AlertStatus.RESOLVED alert.resolved_by = current_user.id alert.resolved_at = datetime.now(UTC) - alert.resolution_notes = resolution + alert.resolution_notes = payload.resolution await db.commit() return {"message": "Alert resolved", "alert": alert.to_dict()} diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index 7379422a..6596af71 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -2,7 +2,7 @@ from datetime import UTC, datetime, timedelta 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 app.db.session import get_db diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 66368051..4aed0860 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -95,6 +95,8 @@ async def init_db(): import app.models.bgp_observation # noqa: F401 import app.models.collected_data # 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: await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 30c52b05..ebc4d6ca 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,6 +9,8 @@ from app.models.bgp_anomaly import BGPAnomaly from app.models.bgp_incident import BGPIncident from app.models.bgp_observation import BGPObservation from app.models.system_setting import SystemSetting +from app.models.playground_session import PlaygroundSession +from app.models.playground_message import PlaygroundMessage __all__ = [ "User", diff --git a/backend/app/models/playground_message.py b/backend/app/models/playground_message.py new file mode 100644 index 00000000..ce85de8c --- /dev/null +++ b/backend/app/models/playground_message.py @@ -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"" diff --git a/backend/app/models/playground_session.py b/backend/app/models/playground_session.py new file mode 100644 index 00000000..0343127f --- /dev/null +++ b/backend/app/models/playground_session.py @@ -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"" diff --git a/backend/app/schemas/ai.py b/backend/app/schemas/ai.py index 3bcdac9c..37e02f45 100644 --- a/backend/app/schemas/ai.py +++ b/backend/app/schemas/ai.py @@ -29,6 +29,17 @@ class BGPBriefRequest(BaseModel): 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): provider: str model: str @@ -50,6 +61,22 @@ class BGPBriefRecordSummary(BaseModel): class BGPBriefRecordResponse(BGPBriefRecordSummary): 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): @@ -59,3 +86,90 @@ class AIProviderStatusResponse(BaseModel): configured: bool model: 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) diff --git a/backend/app/schemas/alert.py b/backend/app/schemas/alert.py new file mode 100644 index 00000000..f8cb4e7e --- /dev/null +++ b/backend/app/schemas/alert.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, Field + + +class AlertResolutionRequest(BaseModel): + resolution: str = Field(..., min_length=1, max_length=1000) diff --git a/backend/app/services/alert_ai_brief.py b/backend/app/services/alert_ai_brief.py new file mode 100644 index 00000000..6051fa61 --- /dev/null +++ b/backend/app/services/alert_ai_brief.py @@ -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, + ) diff --git a/backend/app/services/bgp_ai_brief.py b/backend/app/services/bgp_ai_brief.py index 998b440e..b492deac 100644 --- a/backend/app/services/bgp_ai_brief.py +++ b/backend/app/services/bgp_ai_brief.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections import Counter +from typing import Any from sqlalchemy import func, select 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.schemas.ai import SituationalAnalysisRequest 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: @@ -30,13 +32,59 @@ def _severity_rank(value: str | None) -> int: 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( db: AsyncSession, *, incident_limit: int = 5, anomaly_limit: int = 6, collector_limit: int = 5, -) -> SituationalAnalysisRequest: +) -> tuple[SituationalAnalysisRequest, list[str], dict[str, int | str | dict[str, int]]]: incidents_result = await db.execute( select(BGPIncident) .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) 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) + incident_region_counts = _collect_incident_regions(incidents) top_collectors = sorted( active_collectors, @@ -77,6 +126,29 @@ async def build_bgp_brief_request( str(item["collector"]), ), )[: 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] = [ 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)))}。", ] + 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: observations_lines.append( "最近 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( title="BGP 态势 AI 简报", - objective="基于当前 BGP incidents、anomalies、原始观测事件与观测站覆盖情况,生成一份面向操作员的简明态势简报,突出当前风险、证据和优先动作。", + objective="基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。", observations=observations_lines, constraints=[ "明确区分事实、推断与建议。", "优先指出需要立即关注的高严重度 incident 或异常模式。", + "需要单独指出哪些区域结论来自 prefix geography / affected regions,哪些可能受 collector coverage 偏差影响。", "结论应服务值班排障,不要写成泛泛的模型演示文案。", "如果证据不足,要明确指出缺失数据。", ], - 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)), - }, - ) + context=context, + ), observations_lines, context diff --git a/backend/app/services/bgp_ai_brief_store.py b/backend/app/services/bgp_ai_brief_store.py index 66eb2302..90658060 100644 --- a/backend/app/services/bgp_ai_brief_store.py +++ b/backend/app/services/bgp_ai_brief_store.py @@ -4,6 +4,7 @@ import json from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path +from typing import Any from uuid import uuid4 from app.core.config import ROOT_DIR @@ -25,6 +26,8 @@ class _StoredBrief: request_id: str | None generated_at: str content_markdown: str + facts: list[str] + context: dict[str, Any] path: Path @@ -33,7 +36,7 @@ def _ensure_storage_dir() -> Path: 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}" @@ -62,6 +65,8 @@ def _parse_brief_file(path: Path) -> _StoredBrief | None: request_id=metadata.get("request_id"), generated_at=str(metadata.get("generated_at") or datetime.fromtimestamp(path.stat().st_mtime, UTC).isoformat()), content_markdown=remainder.lstrip("\n"), + facts=list(metadata.get("facts") or []), + context=dict(metadata.get("context") or {}), path=path, ) @@ -104,6 +109,8 @@ def get_bgp_brief_record(brief_id: str) -> BGPBriefRecordResponse | None: request_id=parsed.request_id, generated_at=parsed.generated_at, content_markdown=parsed.content_markdown, + facts=parsed.facts, + context=parsed.context, ) @@ -118,6 +125,8 @@ def save_bgp_brief_record( analysis: SituationalAnalysisResponse, *, request_id: str | None, + facts: list[str] | None = None, + context: dict[str, Any] | None = None, generated_at: datetime | None = None, ) -> BGPBriefRecordResponse: created_at = generated_at or datetime.now(UTC) @@ -131,6 +140,8 @@ def save_bgp_brief_record( "model": analysis.model, "request_id": request_id, "generated_at": created_at.isoformat(), + "facts": facts or [], + "context": context or {}, } 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, generated_at=created_at.isoformat(), content_markdown=analysis.content, + facts=facts or [], + context=context or {}, ) diff --git a/backend/app/services/bgp_enrichment.py b/backend/app/services/bgp_enrichment.py index cc98d7c7..2df3b1d3 100644 --- a/backend/app/services/bgp_enrichment.py +++ b/backend/app/services/bgp_enrichment.py @@ -231,6 +231,13 @@ async def _lookup_prefix_geography( 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( db: AsyncSession, *, diff --git a/backend/app/services/playground_chat_service.py b/backend/app/services/playground_chat_service.py new file mode 100644 index 00000000..9ce32970 --- /dev/null +++ b/backend/app/services/playground_chat_service.py @@ -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) diff --git a/backend/app/services/playground_session_store.py b/backend/app/services/playground_session_store.py new file mode 100644 index 00000000..873d90fa --- /dev/null +++ b/backend/app/services/playground_session_store.py @@ -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) diff --git a/backend/app/services/situational_alert_ai_brief.py b/backend/app/services/situational_alert_ai_brief.py new file mode 100644 index 00000000..e672669f --- /dev/null +++ b/backend/app/services/situational_alert_ai_brief.py @@ -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 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 4d79ee92..2822bf82 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -10,7 +10,12 @@ from app.core.config import settings from app.core.security import create_access_token from app.db.session import get_db from app.models.user import User -from app.schemas.ai import AIProviderStatusResponse, SituationalAnalysisResponse +from app.schemas.ai import ( + AIProviderStatusResponse, + PlaygroundSessionResponse, + PlaygroundSessionState, + SituationalAnalysisResponse, +) @pytest.fixture @@ -258,5 +263,294 @@ async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers): assert "content_blocks" in data assert "text_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: app.dependency_overrides.clear() diff --git a/docs/situational-awareness-foundation-plan.md b/docs/situational-awareness-foundation-plan.md new file mode 100644 index 00000000..f0fa368e --- /dev/null +++ b/docs/situational-awareness-foundation-plan.md @@ -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` 是核心服务底座 + +现阶段不需要追求“已经具备完整态势感知能力”。 + +现阶段真正的成功标准是: + +- 这套底座可用 +- 可回看 +- 可扩展 +- 不自欺欺人 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a99042dd..83dec72f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,14 @@ import { Suspense, lazy } from 'react' + import { Spin } from 'antd' import { Routes, Route, Navigate } from 'react-router-dom' + import { useAuthStore } from './stores/auth' 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 Users = lazy(() => import('./pages/Users/Users')) const DataSources = lazy(() => import('./pages/DataSources/DataSources')) @@ -37,6 +42,10 @@ function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AppLayout/AppLayout.tsx b/frontend/src/components/AppLayout/AppLayout.tsx index 1820a7d9..5a11422f 100644 --- a/frontend/src/components/AppLayout/AppLayout.tsx +++ b/frontend/src/components/AppLayout/AppLayout.tsx @@ -1,6 +1,7 @@ -import { ReactNode, useState } from 'react' +import { ReactNode, useMemo, useState } from 'react' import { Layout, Menu, Typography, Button, Space } from 'antd' import { + AlertOutlined, DashboardOutlined, DatabaseOutlined, UserOutlined, @@ -10,8 +11,13 @@ import { RobotOutlined, MenuUnfoldOutlined, MenuFoldOutlined, + GlobalOutlined, + AppstoreOutlined, + ToolOutlined, + InboxOutlined, } from '@ant-design/icons' import { useLocation, useNavigate } from 'react-router-dom' +import type { ItemType, MenuItemType } from 'antd/es/menu/interface' import { useAuthStore } from '../../stores/auth' import packageJson from '../../../package.json' @@ -27,19 +33,64 @@ function AppLayout({ children }: AppLayoutProps) { const navigate = useNavigate() const { user, logout } = useAuthStore() const [collapsed, setCollapsed] = useState(false) + const [openKeys, setOpenKeys] = useState(['collection']) const showBanner = true const appVersion = `v${packageJson.version}` - const menuItems = [ - { key: '/admin', icon: , label: '仪表盘' }, - { key: '/datasources', icon: , label: '数据源' }, - { key: '/data', icon: , label: '采集数据' }, - { key: '/bgp', icon: , label: 'BGP观测' }, - { key: '/playground', icon: , label: 'AI Playground' }, - { key: '/users', icon: , label: '用户管理' }, - { key: '/settings', icon: , label: '系统配置' }, + const menuItems: ItemType[] = [ + { + key: 'overview', + icon: , + label: '总览', + children: [ + { key: '/admin', icon: , label: '仪表盘' }, + { key: '/earth', icon: , label: 'Earth' }, + ], + }, + { + key: 'collection', + icon: , + label: '采集与数据', + children: [ + { key: '/datasources', icon: , label: '数据源' }, + { key: '/data', icon: , label: '采集数据' }, + ], + }, + { + key: 'observability', + icon: , + label: '专题观测', + children: [ + { key: '/bgp', icon: , label: 'BGP观测' }, + ], + }, + { + key: 'alerts', + icon: , + label: '告警与研判', + children: [ + { key: '/alerts/system', icon: , label: '系统告警' }, + { key: '/alerts/bgp', icon: , label: 'BGP 告警' }, + { key: '/alerts/situational', icon: , label: '态势告警' }, + ], + }, + { + key: 'ops', + icon: , + label: '运维与配置', + children: [ + { key: '/playground', icon: , label: 'AI Playground' }, + { key: '/users', icon: , label: '用户管理' }, + { key: '/settings', icon: , label: '系统配置' }, + ], + }, ] + const selectedKey = useMemo(() => { + if (location.pathname === '/') return '/earth' + return location.pathname + }, [location.pathname]) + return ( { + setCollapsed(nextCollapsed) + if (nextCollapsed) { + setOpenKeys([]) + } + }} className="dashboard-sider" >
@@ -69,10 +125,14 @@ function AppLayout({ children }: AppLayoutProps) { { + setOpenKeys(keys as string[]) + }} onClick={({ key }) => { - if (key !== location.pathname) { + if (key !== selectedKey) { navigate(key) } }} diff --git a/frontend/src/index.css b/frontend/src/index.css index b31073a5..6d8b0f8a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -190,6 +190,11 @@ body { min-height: 0; } +.playground-page { + height: 100%; + min-height: 0; +} + .playground-page__grid { flex: 1 1 auto; min-height: 0; @@ -198,11 +203,15 @@ body { } .playground-page__body { + flex: 1 1 auto; + min-height: 0; + display: flex; overflow: hidden; } .playground-shell { flex: 1 1 auto; + height: 100%; min-height: 0; display: flex; gap: 12px; @@ -303,6 +312,7 @@ body { .playground-card--workspace { flex: 1 1 auto; + height: 100%; } .playground-card--workspace .ant-card-body, @@ -310,6 +320,405 @@ body { 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 .ant-tabs-content-holder, .playground-tabs .ant-tabs-content, @@ -397,6 +806,30 @@ body { 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 { display: flex; gap: 12px; @@ -534,6 +967,11 @@ body { gap: 12px; } +.playground-service-modal__body { + display: grid; + gap: 12px; +} + .playground-note.ant-alert { padding: 10px 12px; } @@ -621,6 +1059,21 @@ body { 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 { display: flex; align-items: center; @@ -638,6 +1091,52 @@ body { 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) { .playground-page__header { align-items: flex-start; @@ -652,9 +1151,7 @@ body { } .playground-shell__sidebar { - flex: 0 0 auto; - min-width: 0; - max-width: none; + display: none; } .playground-result__meta, @@ -662,6 +1159,23 @@ body { align-items: stretch; 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; } +.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) { + .alerts-tab-panel__head { + flex-direction: column; + align-items: stretch; + } + .bgp-page__brief-head { flex-direction: column; } @@ -1100,6 +1678,22 @@ body { 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 { background: #f7f8fa; border-radius: 12px; @@ -1107,6 +1701,8 @@ body { } .bgp-page__brief-modal-body { + display: grid; + gap: 14px; max-height: calc(100vh - 180px); overflow: auto; padding-right: 6px; @@ -1114,6 +1710,89 @@ body { 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 { max-width: min(920px, calc(100vw - 32px)); } diff --git a/frontend/src/pages/Alerts/Alerts.tsx b/frontend/src/pages/Alerts/Alerts.tsx index eeb55004..833454ae 100644 --- a/frontend/src/pages/Alerts/Alerts.tsx +++ b/frontend/src/pages/Alerts/Alerts.tsx @@ -1,221 +1,66 @@ -import { useEffect, useState } 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' +import { useMemo } from 'react' -interface Alert { - id: number - severity: 'critical' | 'warning' | 'info' - status: 'active' | 'acknowledged' | 'resolved' - datasource_name: string - message: string - created_at: string - acknowledged_at?: string - resolved_at?: string -} +import { AlertOutlined, DeploymentUnitOutlined, RadarChartOutlined } from '@ant-design/icons' +import { Tabs, Typography } from 'antd' +import { useSearchParams } from 'react-router-dom' + +import AppLayout from '../../components/AppLayout/AppLayout' +import { BGPAlertsPanel } from './BGPAlerts' +import { SituationalAlertsPanel } from './SituationalAlerts' +import { SystemAlertsPanel } from './SystemAlerts' + +const { Title, Text } = Typography + +const ALERT_TABS = [ + { + key: 'system', + label: '系统告警', + icon: , + children: , + }, + { + key: 'bgp', + label: 'BGP 告警', + icon: , + children: , + }, + { + key: 'situational', + label: '态势告警', + icon: , + children: , + }, +] function Alerts() { - const { token } = useAuthStore() - const [alerts, setAlerts] = useState([]) - const [loading, setLoading] = useState(false) - const [selectedAlert, setSelectedAlert] = useState(null) - const [detailVisible, setDetailVisible] = useState(false) - - 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 = { critical: 'error', warning: 'warning', info: 'blue' } - const icons: Record = { - critical: , - warning: , - info: , - } - return ( - - {s === 'critical' ? '严重' : s === 'warning' ? '警告' : '信息'} - - ) - }, - }, - { - title: '状态', - dataIndex: 'status', - key: 'status', - render: (s: string) => { - const colors: Record = { active: 'red', acknowledged: 'orange', resolved: 'green' } - return ( - - {s === 'active' ? '待处理' : s === 'acknowledged' ? '已确认' : '已解决'} - - ) - }, - }, - { 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) => ( - - {record.status === 'active' && ( - - )} - {record.status !== 'resolved' && ( - - )} - - - ), - }, - ] - - const stats = alerts.reduce( - (acc, alert) => { - if (alert.status === 'active') { - acc[alert.severity]++ - } - return acc - }, - { critical: 0, warning: 0, info: 0 } as Record + const [searchParams, setSearchParams] = useSearchParams() + const requestedTab = searchParams.get('tab') || 'system' + const activeTab = useMemo( + () => (ALERT_TABS.some((item) => item.key === requestedTab) ? requestedTab : 'system'), + [requestedTab], ) return ( - - - - } - /> - - - - - } - /> - - - - - } /> - - - - - } onClick={fetchAlerts}>刷新} - > -
- +
+
+
+ 告警工作台 + + 统一查看系统告警、BGP 告警和跨模块态势告警。主工作区保持单屏,具体证据和 AI 简报在各个 Tab 内处理。 + +
- - setDetailVisible(false)} - footer={null} - width={600} - > - {selectedAlert && ( - - {selectedAlert.id} - - - {selectedAlert.severity} - - - - - {selectedAlert.status} - - - {selectedAlert.datasource_name} - {selectedAlert.message} - {formatDateTimeZhCN(selectedAlert.created_at)} - {selectedAlert.acknowledged_at && ( - - {formatDateTimeZhCN(selectedAlert.acknowledged_at)} - - )} - {selectedAlert.resolved_at && ( - - {formatDateTimeZhCN(selectedAlert.resolved_at)} - - )} - - )} - +
+ setSearchParams({ tab: key })} + items={ALERT_TABS} + /> +
+
) } diff --git a/frontend/src/pages/Alerts/BGPAlerts.tsx b/frontend/src/pages/Alerts/BGPAlerts.tsx new file mode 100644 index 00000000..adb0ab88 --- /dev/null +++ b/frontend/src/pages/Alerts/BGPAlerts.tsx @@ -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([]) + const [anomalies, setAnomalies] = useState([]) + const [briefLoading, setBriefLoading] = useState(false) + const [briefModalOpen, setBriefModalOpen] = useState(false) + const [brief, setBrief] = useState(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 = [ + { + 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) => {value}, + }, + { + title: '状态', + dataIndex: 'status', + width: 120, + render: (value: string) => {value}, + }, + { + title: '影响前缀', + dataIndex: 'affected_prefixes', + width: 220, + render: (value: string[]) => (value && value.length > 0 ? value.join(', ') : '-'), + }, + { title: '摘要', dataIndex: 'summary', width: 320 }, + ] + + const anomalyColumns: TableColumnsType = [ + { + 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) => {value}, + }, + { + title: '状态', + dataIndex: 'status', + width: 120, + render: (value: string) => {value}, + }, + { + title: '前缀', + dataIndex: 'prefix', + width: 200, + render: (value: string | null) => value || '-', + }, + { title: '摘要', dataIndex: 'summary', width: 320 }, + ] + + return ( + +
+ {contextHolder} + +
+
+ BGP 告警 +
把 BGP incidents 与 anomalies 当作告警工作台来快速筛查控制平面风险。
+
+ + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + columns={incidentColumns} + dataSource={incidents} + loading={loading} + pagination={false} + rowKey="id" + scroll={{ x: 1200, y: 480 }} + tableLayout="fixed" + /> + + ), + }, + { + key: 'anomalies', + label: 'BGP 异常', + children: ( +
+ + columns={anomalyColumns} + dataSource={anomalies} + loading={loading} + pagination={false} + rowKey="id" + scroll={{ x: 1100, y: 480 }} + tableLayout="fixed" + /> +
+ ), + }, + ]} + /> +
+ + + setBriefModalOpen(false)} + footer={null} + width={920} + className="bgp-page__brief-modal" + style={{ top: 24 }} + styles={{ body: { paddingTop: 12 } }} + > + {briefLoading ? ( +
+ +
+ ) : brief ? ( +
+ + {brief.provider || '-'} + {brief.model || '-'} + {formatDateTimeZhCN(brief.generated_at)} + + {brief.content_markdown} +
+ ) : ( + 当前没有可查看的 BGP 简报。 + )} +
+ + + ) +} + +export default BGPAlertsPanel diff --git a/frontend/src/pages/Alerts/SituationalAlerts.tsx b/frontend/src/pages/Alerts/SituationalAlerts.tsx new file mode 100644 index 00000000..73299ce2 --- /dev/null +++ b/frontend/src/pages/Alerts/SituationalAlerts.tsx @@ -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(null) + const [bgpSummary, setBgpSummary] = useState(null) + const [loading, setLoading] = useState(false) + const [briefOpen, setBriefOpen] = useState(false) + const [briefLoading, setBriefLoading] = useState(false) + const [briefError, setBriefError] = useState(null) + const [briefResult, setBriefResult] = useState(null) + + const loadOverview = async () => { + setLoading(true) + try { + const [alertStatsResponse, bgpSummaryResponse] = await Promise.all([ + axios.get(`${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(`${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 ( + +
+ {contextHolder} + +
+
+ 态势告警 +
把系统告警与 BGP 风险放在同一视角下综合研判,适合做值班总览和跨模块优先级排序。
+
+ + + + +
+ + + + +
+ } /> + + + + + + } /> + + + + + + + + + + + {String(systemStats?.critical ?? '-')} + {String(systemStats?.warning ?? '-')} + {String(systemStats?.info ?? '-')} + + + + + + + {String(bgpSummary?.incidentSummary?.by_status?.active ?? '-')} + {String(bgpSummary?.incidentSummary?.by_severity?.critical ?? '-')} + {String(bgpSummary?.collectorSummary?.active_collectors ?? '-')} + {String(bgpSummary?.collectorSummary?.recent_24h_events ?? '-')} + + + + + + + setBriefOpen(false)} open={briefOpen}> +
+ {briefLoading ? ( +
+ +
+ ) : null} + {!briefLoading && briefError ? ( + + ) : null} + {!briefLoading && briefResult ? ( + + + + {briefResult.objective} + {`${briefResult.provider} / ${briefResult.model}`} + {String(briefResult.context.active_system_alerts ?? '-')} + {String(briefResult.context.active_bgp_incidents ?? '-')} + + + + + {briefResult.facts.map((fact, index) => ( +
+ {index + 1}. + {fact} +
+ ))} +
+
+ + {briefResult.content} + +
+ ) : null} +
+
+ + + ) +} + +export default SituationalAlertsPanel diff --git a/frontend/src/pages/Alerts/SystemAlerts.tsx b/frontend/src/pages/Alerts/SystemAlerts.tsx new file mode 100644 index 00000000..3be8b54d --- /dev/null +++ b/frontend/src/pages/Alerts/SystemAlerts.tsx @@ -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: , + warning: , + info: , + } + + return ( + + {labelMap[value]} + + ) +} + +function renderAlertStatusTag(value: AlertRecord['status']) { + const colorMap = { active: 'red', acknowledged: 'orange', resolved: 'green' } + const labelMap = { active: '待处理', acknowledged: '已确认', resolved: '已解决' } + return {labelMap[value]} +} + +export function SystemAlertsPanel() { + const [messageApi, contextHolder] = message.useMessage() + const [alerts, setAlerts] = useState([]) + const [loading, setLoading] = useState(false) + const [selectedAlert, setSelectedAlert] = useState(null) + const [detailVisible, setDetailVisible] = useState(false) + const [briefOpen, setBriefOpen] = useState(false) + const [briefLoading, setBriefLoading] = useState(false) + const [briefError, setBriefError] = useState(null) + const [briefResult, setBriefResult] = useState(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, + ), + [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(`${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 = [ + { 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) => ( + + {record.status === 'active' ? ( + + ) : null} + {record.status !== 'resolved' ? ( + + ) : null} + + + ), + }, + ] + + return ( + +
+ {contextHolder} + +
+
+ 系统告警 +
聚焦平台运行、采集链路和系统内部异常。
+
+ + + + +
+ + + + +
+ } /> + + + } /> + + + } /> + + + + +
+ + columns={columns} + dataSource={alerts} + loading={loading} + pagination={{ pageSize: 10 }} + rowKey="id" + scroll={{ x: 1100, y: 480 }} + tableLayout="fixed" + /> +
+
+ + + setDetailVisible(false)} footer={null} width={640}> + {selectedAlert ? ( + + {selectedAlert.id} + {renderAlertSeverityTag(selectedAlert.severity)} + {renderAlertStatusTag(selectedAlert.status)} + {selectedAlert.datasource_name || '-'} + {selectedAlert.message} + {formatDateTimeZhCN(selectedAlert.created_at)} + {formatDateTimeZhCN(selectedAlert.acknowledged_at || null)} + {formatDateTimeZhCN(selectedAlert.resolved_at || null)} + {selectedAlert.resolution_notes || '-'} + + ) : null} + + + setBriefOpen(false)} open={briefOpen}> +
+ {briefLoading ? ( +
+ +
+ ) : null} + {!briefLoading && briefError ? ( + + ) : null} + {!briefLoading && briefResult ? ( + + + + {briefResult.objective} + {`${briefResult.provider} / ${briefResult.model}`} + {String(briefResult.context.active_alerts ?? '-')} + + + + + {briefResult.facts.map((fact, index) => ( +
+ {index + 1}. + {fact} +
+ ))} +
+
+ + {briefResult.content} + +
+ ) : null} +
+
+ + + ) +} + +export default SystemAlertsPanel diff --git a/frontend/src/pages/BGP/BGP.tsx b/frontend/src/pages/BGP/BGP.tsx index 2eb83801..e16aa54c 100644 --- a/frontend/src/pages/BGP/BGP.tsx +++ b/frontend/src/pages/BGP/BGP.tsx @@ -66,6 +66,19 @@ function sortBriefRecords(records: T[]) { 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) + if (entries.length === 0) return '-' + return entries.map(([key, count]) => `${key}: ${String(count)}`).join(',') + } + return String(value) +} + function renderCollectorLocation(_: unknown, record: BGPCollectorCoverage) { return [record.city, record.country].filter(Boolean).join(', ') || '-' } @@ -574,7 +587,23 @@ function BGP() { {formatDateTimeZhCN(brief.generated_at)} + {brief.facts.length} + {renderBriefContextValue(brief.context.incident_total)} + {renderBriefContextValue(brief.context.active_collectors)} + {brief.facts.length > 0 ? ( +
+ 事实快照 +
+ {brief.facts.slice(0, 3).map((fact, index) => ( +
+ {index + 1}. + {fact} +
+ ))} +
+
+ ) : null} ) : (
@@ -727,6 +756,34 @@ function BGP() { > {brief ? (
+ {(brief.facts.length > 0 || Object.keys(brief.context || {}).length > 0) ? ( +
+ {brief.facts.length > 0 ? ( + + + {brief.facts.map((fact, index) => ( +
+ {index + 1}. + {fact} +
+ ))} +
+
+ ) : null} + + {Object.keys(brief.context || {}).length > 0 ? ( + + + {Object.entries(brief.context).map(([key, value]) => ( + + {renderBriefContextValue(value)} + + ))} + + + ) : null} +
+ ) : null}
) : ( diff --git a/frontend/src/pages/Playground/Playground.tsx b/frontend/src/pages/Playground/Playground.tsx index 17660e2a..1f20417f 100644 --- a/frontend/src/pages/Playground/Playground.tsx +++ b/frontend/src/pages/Playground/Playground.tsx @@ -1,26 +1,36 @@ -import { useEffect, useState } from 'react' -import axios from 'axios' -import { CopyOutlined, ReloadOutlined, SyncOutlined } from '@ant-design/icons' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { ApiOutlined, ArrowDownOutlined, ArrowUpOutlined, BorderOutlined, CopyOutlined, EditOutlined, InfoCircleOutlined, RedoOutlined, SettingOutlined, SyncOutlined } from '@ant-design/icons' import { Alert, + Avatar, Button, Card, Collapse, Descriptions, - Form, + Drawer, Input, + Modal, Space, Spin, - Tabs, Tag, + Tooltip, Typography, message, } from 'antd' -import AppLayout from '../../components/AppLayout/AppLayout' +import axios from 'axios' +import { RobotOutlined, UserOutlined } from '@ant-design/icons' -const { Title, Text } = Typography +import AppLayout from '../../components/AppLayout/AppLayout' +import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer' +import { useAuthStore } from '../../stores/auth' + +const { Title, Text, Paragraph } = Typography const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1' const PLAYGROUND_PROVIDER_STATUS_STORAGE_KEY = 'playground-provider-status' +const PLAYGROUND_REMOTE_SESSION_KEY = 'default' +const THREAD_POLL_INTERVAL_MS = 1200 +const SCROLL_BOTTOM_THRESHOLD_PX = 120 interface AIProviderStatus { provider: string @@ -47,17 +57,17 @@ interface SituationalAnalysisResponse { raw_response: Record } -interface PlaygroundFormValues { +interface PlaygroundPresetValues { title: string objective: string - observations?: string constraints?: string + message: string } interface PlaygroundPreset { key: string label: string - values: PlaygroundFormValues + values: PlaygroundPresetValues } interface AnalysisRunMeta { @@ -66,6 +76,76 @@ interface AnalysisRunMeta { completedAt: string | null } +interface PlaygroundSessionState { + selectedPresetKey: string + title: string + objective: string + constraints: string + inputValue: string + analysis: SituationalAnalysisResponse | null + latestAnalysisMessageId: string | null + analysisMeta: AnalysisRunMeta + helpExpanded: boolean +} + +interface PlaygroundSessionRecord { + id: string + session_key: string + title: string + state: PlaygroundSessionState + created_at: string + updated_at: string +} + +interface PlaygroundThreadRecord { + session: PlaygroundSessionRecord + messages: PlaygroundApiMessage[] +} + +interface PlaygroundApiMessage { + id: string + role: 'system' | 'user' | 'assistant' + kind?: 'message' | 'thinking' + status?: 'pending' | 'thinking' | 'answering' | 'done' | 'stopped' | 'error' + title?: string + content: string + thinking_content?: string + meta?: string[] + markdown?: boolean + provider?: string | null + model?: string | null + request_id?: string | null + raw_response?: Record + content_blocks?: AIContentBlock[] + text_blocks?: string[] + thinking_blocks?: string[] + parent_message_id?: string | null + created_at: string + updated_at: string +} + +interface PlaygroundMessage { + id: string + role: 'system' | 'user' | 'assistant' + kind?: 'message' | 'thinking' + status?: 'pending' | 'thinking' | 'answering' | 'done' | 'stopped' | 'error' + title?: string + content: string + thinkingContent?: string + meta?: string[] + markdown?: boolean + provider?: string | null + model?: string | null + requestId?: string | null + rawResponse?: Record + contentBlocks?: AIContentBlock[] + textBlocks?: string[] + thinkingBlocks?: string[] + parentMessageId?: string | null + createdAt?: string + updatedAt?: string +} + const PLAYGROUND_PRESETS: PlaygroundPreset[] = [ { key: 'bgp-brief', @@ -73,8 +153,8 @@ const PLAYGROUND_PRESETS: PlaygroundPreset[] = [ values: { title: 'BGP 告警态势简报', objective: '总结当前告警的主要风险、优先级与建议动作。', - observations: '出现新的高危告警\n部分观测站最近 24h 事件增多', constraints: '结论要简洁\n优先给出操作建议', + message: '出现新的高危告警\n部分观测站最近 24h 事件增多\n请先给出风险摘要,再列出建议动作。', }, }, { @@ -83,8 +163,8 @@ const PLAYGROUND_PRESETS: PlaygroundPreset[] = [ values: { title: '采集器健康检查说明', objective: '判断当前采集器失败是否属于上游接口失效、限流、结构变更或临时波动。', - observations: '最近 3 次任务失败\n部分数据源响应时间抬升\n个别接口返回结构不稳定', constraints: '区分事实与推断\n先给排障优先级', + message: '最近 3 次任务失败\n部分数据源响应时间抬升\n个别接口返回结构不稳定\n请帮我先做排障优先级排序。', }, }, { @@ -93,34 +173,202 @@ const PLAYGROUND_PRESETS: PlaygroundPreset[] = [ values: { title: 'AI 链路探测', objective: '验证 backend -> aiprovider -> model provider 调用链路是否正常。', - observations: '当前从 Playground 发起测试\n希望确认 provider 配置和返回结构正常', constraints: '输出简洁\n包含一段明确结论', + message: '当前从 Playground 发起测试,希望确认 provider 配置和返回结构正常,请直接给我链路结论。', }, }, ] -function splitLines(value?: string) { - return (value || '') - .split('\n') - .map((item) => item.trim()) - .filter(Boolean) +function toPlaygroundMessage(item: PlaygroundApiMessage): PlaygroundMessage { + return { + id: item.id, + role: item.role, + kind: item.kind || 'message', + status: item.status, + title: item.title, + content: item.content || '', + thinkingContent: item.thinking_content || '', + meta: item.meta || [], + markdown: item.markdown ?? item.role !== 'system', + provider: item.provider, + model: item.model, + requestId: item.request_id, + rawResponse: item.raw_response || {}, + contentBlocks: item.content_blocks || [], + textBlocks: item.text_blocks || [], + thinkingBlocks: item.thinking_blocks || [], + parentMessageId: item.parent_message_id || null, + createdAt: item.created_at, + updatedAt: item.updated_at, + } } function Playground() { - const [form] = Form.useForm() const [messageApi, contextHolder] = message.useMessage() + const { token } = useAuthStore() const [statusLoading, setStatusLoading] = useState(false) - const [analyzing, setAnalyzing] = useState(false) const [providerStatus, setProviderStatus] = useState(null) + const [providerStatusUpdatedAt, setProviderStatusUpdatedAt] = useState(null) + const [helpExpanded, setHelpExpanded] = useState(true) + const [settingsOpen, setSettingsOpen] = useState(false) + const [servicePanelOpen, setServicePanelOpen] = useState(false) + const [selectedPresetKey, setSelectedPresetKey] = useState(PLAYGROUND_PRESETS[0].key) + const [title, setTitle] = useState(PLAYGROUND_PRESETS[0].values.title) + const [objective, setObjective] = useState(PLAYGROUND_PRESETS[0].values.objective) + const [constraints, setConstraints] = useState(PLAYGROUND_PRESETS[0].values.constraints || '') + const [inputValue, setInputValue] = useState(PLAYGROUND_PRESETS[0].values.message) + const [messages, setMessages] = useState([ + { + id: 'playground-system-intro', + role: 'system', + title: 'Playground 已就绪', + content: '这里是 AI 测试台。选择一个快速预设,补充输入消息,然后发送一次真实后端链路请求。', + }, + ]) const [analysis, setAnalysis] = useState(null) + const [detailOpen, setDetailOpen] = useState(false) + const [latestAnalysisMessageId, setLatestAnalysisMessageId] = useState(null) const [analysisMeta, setAnalysisMeta] = useState({ requestId: null, durationMs: null, completedAt: null, }) - const [providerStatusUpdatedAt, setProviderStatusUpdatedAt] = useState(null) - const [activeTab, setActiveTab] = useState<'request' | 'result'>('request') - const [helpExpanded, setHelpExpanded] = useState(true) + const [requestPending, setRequestPending] = useState(false) + const [editingMessageId, setEditingMessageId] = useState(null) + const [editingContent, setEditingContent] = useState('') + const [editSaving, setEditSaving] = useState(false) + const [showScrollToBottom, setShowScrollToBottom] = useState(false) + const pollTimerRef = useRef(null) + const messagesContainerRef = useRef(null) + const forceScrollToBottomRef = useRef(true) + + const selectedPreset = useMemo( + () => PLAYGROUND_PRESETS.find((item) => item.key === selectedPresetKey) || PLAYGROUND_PRESETS[0], + [selectedPresetKey], + ) + const activeAssistantMessage = useMemo( + () => messages.find((item) => item.role === 'assistant' && ['pending', 'thinking', 'answering'].includes(item.status || '')), + [messages], + ) + const streaming = Boolean(activeAssistantMessage) + const sendButtonTooltip = requestPending ? '发送中' : streaming ? '停止生成' : '发送消息' + + const applyThreadSnapshot = (thread: PlaygroundThreadRecord | null) => { + if (!thread) { + return + } + forceScrollToBottomRef.current = true + const sessionState = thread.session.state + setSelectedPresetKey(sessionState.selectedPresetKey || PLAYGROUND_PRESETS[0].key) + setTitle(sessionState.title || PLAYGROUND_PRESETS[0].values.title) + setObjective(sessionState.objective || PLAYGROUND_PRESETS[0].values.objective) + setConstraints(sessionState.constraints || '') + setInputValue(sessionState.inputValue || '') + setHelpExpanded(sessionState.helpExpanded ?? true) + const remoteMessages = thread.messages.map(toPlaygroundMessage) + setMessages([ + { + id: 'playground-system-intro', + role: 'system', + title: 'Playground 已就绪', + content: '这里是 AI 测试台。聊天记录与执行状态现在以后台数据库为准,刷新后会恢复到真实进度。', + }, + ...remoteMessages, + ]) + setLatestAnalysisMessageId(sessionState.latestAnalysisMessageId || null) + setAnalysis((sessionState.analysis as SituationalAnalysisResponse | null) || null) + setAnalysisMeta(sessionState.analysisMeta || { + requestId: null, + durationMs: null, + completedAt: null, + }) + } + + const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { + const container = messagesContainerRef.current + if (!container) { + return + } + container.scrollTo({ + top: container.scrollHeight, + behavior, + }) + setShowScrollToBottom(false) + } + + const handleMessagesScroll = () => { + const container = messagesContainerRef.current + if (!container) { + return + } + const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight + setShowScrollToBottom(distanceFromBottom > SCROLL_BOTTOM_THRESHOLD_PX) + } + + useEffect(() => { + const container = messagesContainerRef.current + if (!container) { + return + } + if (forceScrollToBottomRef.current) { + forceScrollToBottomRef.current = false + scrollToBottom('auto') + return + } + const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight + if (distanceFromBottom <= SCROLL_BOTTOM_THRESHOLD_PX || requestPending) { + scrollToBottom('auto') + } + }, [messages, requestPending, streaming]) + + const refreshThread = async (options?: { silent?: boolean }) => { + if (!token) { + return + } + + try { + const res = await fetch(`${API_BASE_URL}/ai/playground/thread?session_key=${encodeURIComponent(PLAYGROUND_REMOTE_SESSION_KEY)}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + if (res.ok) { + const thread = (await res.json()) as PlaygroundThreadRecord | null + applyThreadSnapshot(thread) + } + } catch { + if (!options?.silent) { + messageApi.error('Playground 会话加载失败') + } + } finally { + } + } + + useEffect(() => { + void refreshThread() + }, [token]) + + useEffect(() => { + if (pollTimerRef.current !== null) { + window.clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + + if (!token || !activeAssistantMessage) { + return + } + + pollTimerRef.current = window.setInterval(() => { + void refreshThread({ silent: true }) + }, THREAD_POLL_INTERVAL_MS) + + return () => { + if (pollTimerRef.current !== null) { + window.clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + } + }, [token, activeAssistantMessage?.id]) const loadProviderStatus = async (force = false) => { if (!force) { @@ -149,43 +397,94 @@ function Playground() { useEffect(() => { void loadProviderStatus() + return () => { + if (pollTimerRef.current !== null) { + window.clearInterval(pollTimerRef.current) + } + } }, []) - const handleAnalyze = async (values: PlaygroundFormValues) => { - const startedAt = performance.now() - setAnalyzing(true) - setActiveTab('result') + const handleApplyPreset = (preset: PlaygroundPreset) => { + setSelectedPresetKey(preset.key) + setTitle(preset.values.title) + setObjective(preset.values.objective) + setConstraints(preset.values.constraints || '') + setInputValue(preset.values.message) + } + + const handleStop = async () => { + if (!token || !activeAssistantMessage) { + return + } try { - const res = await axios.post( - `${API_BASE_URL}/ai/situational-awareness/analyze`, - { - title: values.title.trim(), - objective: values.objective.trim(), - observations: splitLines(values.observations), - constraints: splitLines(values.constraints), - context: { - source: 'playground', - }, + const res = await fetch(`${API_BASE_URL}/ai/playground/messages/stop`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, }, - ) - setAnalysis(res.data) - setAnalysisMeta({ - requestId: typeof res.headers['x-request-id'] === 'string' ? res.headers['x-request-id'] : null, - durationMs: Math.round(performance.now() - startedAt), - completedAt: new Date().toLocaleString(), + body: JSON.stringify({ + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + message_id: activeAssistantMessage.id, + }), }) - messageApi.success('分析已完成') + if (!res.ok) { + throw new Error('STOP_FAILED') + } + const data = (await res.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } + applyThreadSnapshot({ + session: data.session, + messages: data.messages, + }) + messageApi.warning('已停止生成,已输出内容会保留') } catch { - messageApi.error('分析失败,请检查 AI Provider 配置或稍后再试') - setActiveTab('request') - } finally { - setAnalyzing(false) + messageApi.error('停止失败,请稍后再试') } } - const handleApplyPreset = (preset: PlaygroundPreset) => { - form.setFieldsValue(preset.values) - setActiveTab('request') + const handleSend = async (overrides?: { input?: string; title?: string; objective?: string }) => { + const trimmedInput = (overrides?.input ?? inputValue).trim() + const trimmedTitle = (overrides?.title ?? title).trim() + const trimmedObjective = (overrides?.objective ?? objective).trim() + + if (!trimmedInput || !trimmedTitle || !trimmedObjective) { + messageApi.error('请先补齐标题、目标和输入内容') + return + } + + setRequestPending(true) + + try { + const res = await fetch(`${API_BASE_URL}/ai/playground/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + title: trimmedTitle, + objective: trimmedObjective, + constraints, + input: trimmedInput, + selected_preset_key: selectedPreset.key, + help_expanded: helpExpanded, + }), + }) + if (!res.ok) { + throw new Error('SEND_FAILED') + } + const data = (await res.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } + applyThreadSnapshot({ + session: data.session, + messages: data.messages, + }) + setRequestPending(false) + void refreshThread({ silent: true }) + } catch { + setRequestPending(false) + messageApi.error('分析失败,请检查 AI Provider 配置或稍后再试') + } } const handleCopyResult = async () => { @@ -208,6 +507,205 @@ function Playground() { } } + const handleCopyMessage = async (content: string) => { + try { + await navigator.clipboard.writeText(content) + messageApi.success('消息已复制') + } catch { + messageApi.error('复制失败,请稍后再试') + } + } + + const handleResendMessage = async (entry: PlaygroundMessage) => { + if (!token) { + return + } + try { + const res = await fetch(`${API_BASE_URL}/ai/playground/messages/resend`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + user_message_id: entry.id, + }), + }) + if (!res.ok) { + throw new Error('RESEND_FAILED') + } + const data = (await res.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } + applyThreadSnapshot({ + session: data.session, + messages: data.messages, + }) + messageApi.success('已重试,并清理该消息之后的旧分支') + } catch { + messageApi.error('重试失败,请稍后再试') + } + } + + const handleStartEditMessage = (entry: PlaygroundMessage) => { + setEditingMessageId(entry.id) + setEditingContent(entry.content) + } + + const handleCancelEditMessage = () => { + setEditingMessageId(null) + setEditingContent('') + setEditSaving(false) + } + + const handleSaveEditMessage = async (entry: PlaygroundMessage) => { + if (!token) { + return + } + const trimmed = editingContent.trim() + if (!trimmed) { + messageApi.error('Prompt 不能为空') + return + } + + try { + setEditSaving(true) + const editRes = await fetch(`${API_BASE_URL}/ai/playground/messages/edit`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + user_message_id: entry.id, + content: trimmed, + }), + }) + if (!editRes.ok) { + throw new Error('EDIT_FAILED') + } + const resendRes = await fetch(`${API_BASE_URL}/ai/playground/messages/resend`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + session_key: PLAYGROUND_REMOTE_SESSION_KEY, + user_message_id: entry.id, + }), + }) + if (!resendRes.ok) { + throw new Error('RESEND_AFTER_EDIT_FAILED') + } + const data = (await resendRes.json()) as { messages: PlaygroundApiMessage[], session: PlaygroundSessionRecord } + applyThreadSnapshot({ + session: data.session, + messages: data.messages, + }) + setEditingMessageId(null) + setEditingContent('') + setEditSaving(false) + messageApi.success('Prompt 已更新,已基于新内容重新生成') + } catch { + setEditSaving(false) + messageApi.error('修改或重试失败,请稍后再试') + } + } + + const providerStatusPanel = ( + } + onClick={() => void loadProviderStatus(true)} + aria-label="刷新 Provider 状态" + className="playground-card__icon-button" + /> + )} + > +
+ + {providerStatus ? ( +
+
+ Provider + {providerStatus.provider || '-'} +
+
+ 模型 + {providerStatus.model || '-'} +
+
+ API + {providerStatus.api || '-'} +
+
+ 状态 +
+ + {providerStatus.enabled ? 'enabled' : 'disabled'} + + + {providerStatus.configured ? 'configured' : 'not configured'} + +
+
+
+ Base URL + + {providerStatus.base_url || '-'} + +
+
+ 最后同步 + {providerStatusUpdatedAt || '-'} +
+
+ ) : ( + + )} +
+
+
+ ) + + const helpPanel = ( + setHelpExpanded(Array.isArray(keys) ? keys.includes('help') : keys === 'help')} + items={[ + { + key: 'help', + label: '测试说明', + children: ( +
+ + +
+ ), + }, + ]} + /> + ) + return ( {contextHolder} @@ -216,7 +714,7 @@ function Playground() {
AI Playground - 在前端测试 AI 能力,但实际请求仍统一经由主后端转发到 AI Provider。 + 这里现在是一个真实链路的 AI Chatbox。页面负责调试输入与展示,模型请求仍统一经由主后端转发到 AI Provider。
@@ -224,277 +722,376 @@ function Playground() {
- } - onClick={() => void loadProviderStatus(true)} - aria-label="刷新 Provider 状态" - className="playground-card__icon-button" - /> - )} - > -
-
- - {providerStatus ? ( -
-
- Provider - {providerStatus.provider || '-'} -
-
- 模型 - {providerStatus.model || '-'} -
-
- API - {providerStatus.api || '-'} -
-
- 状态 -
- - {providerStatus.enabled ? 'enabled' : 'disabled'} - - - {providerStatus.configured ? 'configured' : 'not configured'} - -
-
-
- Base URL - - {providerStatus.base_url || '-'} - -
-
- 最后同步 - {providerStatusUpdatedAt || '-'} -
-
- ) : ( - - )} -
-
-
-
- - setHelpExpanded(Array.isArray(keys) ? keys.includes('help') : keys === 'help')} - items={[ - { - key: 'help', - label: '测试说明', - children: ( -
- - 当前链路: - frontend /playground - {' -> '} - backend /api/v1/ai/* - {' -> '} - aiprovider /v1/* - - )} - /> - -
- ), - }, - ]} - /> + {providerStatusPanel} + {helpPanel}
- - setActiveTab(key as 'request' | 'result')} - className="playground-tabs" - items={[ - { - key: 'request', - label: '请求', - children: ( -
-
- 快速预设 -
- {PLAYGROUND_PRESETS.map((preset) => ( - - ))} -
-
-
- - - - - - - - -
- - - - - - - -
- -
- - -
- -
- ), - }, - { - key: 'result', - label: '结果', - children: ( -
- {analyzing ? ( -
- - AI 正在生成结果... -
- ) : analysis ? ( - -
-
- {analysis.provider} - {analysis.model} -
- - - - -
- -
-
{analysis.content}
-
- {analysis.text_blocks.length ? ( -
- 文本块 -
- {analysis.text_blocks.map((block, index) => ( - -
{block}
-
- ))} -
-
- ) : null} - {analysis.thinking_blocks.length ? ( -
- Thinking Blocks -
- {analysis.thinking_blocks.map((block, index) => ( - -
{block}
-
- ))} -
-
- ) : null} -
-
- Raw Response - -
-
- -
{JSON.stringify(analysis.raw_response, null, 2)}
-
-
-
-
- ) : ( - + + + +
+
+
+ ) : (entry.role !== 'assistant' || entry.status === 'answering' || entry.status === 'done' || entry.status === 'stopped' || entry.status === 'error') && entry.markdown ? ( +
+ +
+ ) : (entry.role !== 'assistant' || entry.status === 'answering' || entry.status === 'done' || entry.status === 'stopped' || entry.status === 'error') ? ( +
{entry.content || ' '}
+ ) : null} + {(entry.role !== 'assistant' || entry.status === 'done' || entry.status === 'stopped' || entry.status === 'error') && !(entry.role === 'user' && editingMessageId === entry.id) && entry.meta?.length ? ( +
+ {entry.meta.map((item) => ( + {item} + ))} +
+ ) : null} + + {!(entry.role === 'user' && editingMessageId === entry.id) ? ( +
+ {(entry.role === 'assistant' || entry.role === 'user') ? ( + +
+ ) : null} + - ), - }, - ]} - /> + ))} + + {showScrollToBottom ? ( + + + + + + + + +
+ + {analysis.text_blocks.length ? ( +
+ 文本块 +
+ {analysis.text_blocks.map((block, index) => ( + +
{block}
+
+ ))} +
+
+ ) : null} + {analysis.thinking_blocks.length ? ( +
+ Thinking Blocks +
+ {analysis.thinking_blocks.map((block, index) => ( + +
{block}
+
+ ))} +
+
+ ) : null} +
+ Raw Response +
+ +
{JSON.stringify(analysis.raw_response, null, 2)}
+
+
+
+
+
+ + ) : null} + ) } diff --git a/frontend/src/services/situational-awareness/index.ts b/frontend/src/services/situational-awareness/index.ts index 3e62a6e0..843493c7 100644 --- a/frontend/src/services/situational-awareness/index.ts +++ b/frontend/src/services/situational-awareness/index.ts @@ -1,6 +1,5 @@ import type { SituationalAwarenessGateway } from './port' import { HttpSituationalAwarenessGateway } from './http-gateway' -import { MockSituationalAwarenessGateway } from './mock-gateway' export * from './types' export type { SituationalAwarenessGateway } from './port' @@ -8,10 +7,6 @@ export type { SituationalAwarenessGateway } from './port' let singleton: SituationalAwarenessGateway | null = null export function createSituationalAwarenessGateway(): SituationalAwarenessGateway { - const provider = (import.meta as any).env?.VITE_SA_GATEWAY || 'http' - if (provider === 'mock') { - return new MockSituationalAwarenessGateway() - } return new HttpSituationalAwarenessGateway() } diff --git a/frontend/src/services/situational-awareness/mock-gateway.ts b/frontend/src/services/situational-awareness/mock-gateway.ts deleted file mode 100644 index e73fe066..00000000 --- a/frontend/src/services/situational-awareness/mock-gateway.ts +++ /dev/null @@ -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 { - return EMPTY_SNAPSHOT - } - - async getBGPSummary(): Promise { - 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 { - return this.brief - } - - async listBGPBriefs(): Promise { - return [this.brief] - } - - async getBGPBrief(_briefId: string): Promise { - return this.brief - } - - async getLatestBGPBrief(): Promise { - return this.brief - } -} diff --git a/frontend/src/services/situational-awareness/types.ts b/frontend/src/services/situational-awareness/types.ts index 4279e3e1..fa20bbf3 100644 --- a/frontend/src/services/situational-awareness/types.ts +++ b/frontend/src/services/situational-awareness/types.ts @@ -144,4 +144,47 @@ export interface BGPBriefRecordSummary { export interface BGPBriefRecord extends BGPBriefRecordSummary { content_markdown: string + facts: string[] + context: Record +} + +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 + title: string + objective: string + facts: string[] + context: Record +} + +export interface SituationalAlertBriefResponse { + provider: string + model: string + content: string + content_blocks: AIContentBlock[] + text_blocks: string[] + thinking_blocks: string[] + raw_response: Record + title: string + objective: string + facts: string[] + context: Record }