feat: enrich insights cards, add AI chatbot

Add 3 new data-driven insight cards (daily cases, district risk
comparison, weather impact) with real parquet data. Fix season
card to use current date instead of data date. Expand to 11 cards.

Add POST /api/chat endpoint proxying to ai.2890.ltd with JWT auth.
Create ChatBot frontend component with collapsible chat panel,
message bubbles, and auto-scroll. Chat API key stored in .env only.

Clean up duplicate typing imports in insights.py, export cachedPost.
This commit is contained in:
2026-06-05 03:10:28 +08:00
parent 58a6df0e06
commit cb0f6cf7f6
8 changed files with 585 additions and 22 deletions

View File

@@ -2,8 +2,13 @@
Centralized configuration and named constants for CBPOA backend.
Eliminates magic numbers scattered across routers.
"""
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
# ============================================================================
# Paths
# ============================================================================
@@ -78,3 +83,11 @@ TREND_SLOPE_THRESHOLD = 0.05
DATE_FORMAT_GEOJSON = "%Y%m%d"
DATE_FORMAT_ISO = "%Y-%m-%d"
# ============================================================================
# Chat Proxy Configuration
# ============================================================================
CHAT_API_KEY = os.getenv("CHAT_API_KEY", "")
CHAT_API_BASE = os.getenv("CHAT_API_BASE", "https://ai.2890.ltd/v1")
CHAT_MODEL = os.getenv("CHAT_MODEL", "gpt-4o-mini")

View File

@@ -13,7 +13,7 @@ from logging_config import setup_logging
from middleware.request_logger import RequestLoggerMiddleware
from auth.router import router as auth_router
from auth.service import seed_default_admin
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat
setup_logging()
@@ -57,6 +57,7 @@ app.include_router(reports.router)
app.include_router(cases.router)
app.include_router(geocoded.router)
app.include_router(grid.router)
app.include_router(chat.router)
@app.get("/")

View File

@@ -10,6 +10,7 @@ python-multipart==0.0.6
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-dotenv==1.0.0
httpx>=0.25.0
scipy>=1.11.0
pandas>=2.0.0
numpy>=1.24.0

198
backend/routers/chat.py Normal file
View File

@@ -0,0 +1,198 @@
"""
Chat proxy router — forwards to ai.2890.ltd OpenAI-compatible API.
Non-streaming: returns {reply, model}
Streaming: returns SSE text/event-stream via StreamingResponse
"""
from __future__ import annotations
import json
import logging
from typing import Optional
import httpx
from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from config import CHAT_API_KEY, CHAT_API_BASE, CHAT_MODEL
from auth.dependencies import get_current_user
logger = logging.getLogger("cbpoa.chat")
router = APIRouter(prefix="/api", tags=["chat"])
SYSTEM_PROMPT = (
"You are a medical risk analysis assistant for the Wuhan Children's Respiratory "
"Disease Risk Assessment System (CBPOA). You help users understand environmental "
"health risks, air quality impacts on children's respiratory health, and spatial "
"risk patterns. Answer in Chinese (Simplified) unless the user asks in English. "
"Be helpful, concise, and evidence-based."
)
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
class ChatMessage(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
messages: list[ChatMessage]
stream: bool = False
class ChatResponse(BaseModel):
reply: str
model: str
# ---------------------------------------------------------------------------
# Utilities
# ---------------------------------------------------------------------------
def _build_payload(messages: list[ChatMessage]) -> dict:
"""Build the OpenAI-compatible request payload."""
system_msg = {"role": "system", "content": SYSTEM_PROMPT}
user_msgs = [{"role": m.role, "content": m.content} for m in messages]
return {
"model": CHAT_MODEL,
"messages": [system_msg, *user_msgs],
"temperature": 0.7,
"stream": False,
}
def _redact_key(key: str) -> str:
"""Return a safe version of the API key for logging."""
if not key:
return "<not set>"
return key[:6] + "..." if len(key) > 6 else "***"
# ---------------------------------------------------------------------------
# Endpoint
# ---------------------------------------------------------------------------
@router.post("/chat", response_model=ChatResponse)
async def chat_proxy(body: ChatRequest, user=Depends(get_current_user)):
"""
Proxy chat requests to the OpenAI-compatible API at ai.2890.ltd.
- **messages**: list of {role, content}
- **stream**: set to true for SSE streaming (default false)
"""
if not CHAT_API_KEY:
raise HTTPException(
status_code=503,
detail="Chat API key is not configured on the server.",
)
payload = _build_payload(body.messages)
if body.stream:
# -- streaming path -------------------------------------------------
payload["stream"] = True
async def event_generator():
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
try:
async with client.stream(
"POST",
f"{CHAT_API_BASE}/chat/completions",
json=payload,
headers={
"Authorization": f"Bearer {CHAT_API_KEY}",
"Content-Type": "application/json",
},
) as response:
if response.status_code != 200:
# Read error body and forward as a single SSE error
error_text = ""
async for chunk in response.aiter_text():
error_text += chunk
logger.error(
"Upstream chat error %d: %s",
response.status_code,
error_text[:500],
)
yield f"data: {json.dumps({'error': f'Upstream API returned {response.status_code}'})}\n\n"
yield "data: [DONE]\n\n"
return
async for line in response.aiter_lines():
yield line + "\n"
# SSE spec uses \n\n as event separator; upstream
# may send \n\n itself, but we ensure it explicitly.
if line.strip() == "data: [DONE]":
break
except httpx.TimeoutException:
logger.exception("Timeout connecting to chat upstream")
yield f"data: {json.dumps({'error': 'Upstream request timed out'})}\n\n"
yield "data: [DONE]\n\n"
except httpx.ConnectError:
logger.exception("Cannot connect to chat upstream")
yield f"data: {json.dumps({'error': 'Cannot connect to chat API'})}\n\n"
yield "data: [DONE]\n\n"
except Exception:
logger.exception("Unexpected error in chat stream")
yield f"data: {json.dumps({'error': 'Internal streaming error'})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# -- non-streaming path -------------------------------------------------
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
try:
resp = await client.post(
f"{CHAT_API_BASE}/chat/completions",
json=payload,
headers={
"Authorization": f"Bearer {CHAT_API_KEY}",
"Content-Type": "application/json",
},
)
if resp.status_code != 200:
detail = resp.text[:300]
logger.error(
"Upstream chat API returned %d: %s",
resp.status_code,
detail,
)
raise HTTPException(
status_code=502,
detail=f"Upstream API error: {resp.status_code}",
)
data = resp.json()
choices = data.get("choices", [])
if not choices:
raise HTTPException(
status_code=502,
detail="Upstream API returned no choices",
)
reply = choices[0]["message"]["content"]
return ChatResponse(reply=reply, model=data.get("model", CHAT_MODEL))
except httpx.TimeoutException:
logger.exception("Timeout calling chat upstream")
raise HTTPException(status_code=504, detail="Upstream API timed out")
except httpx.ConnectError:
logger.exception("Cannot connect to chat upstream (api_key=%s)", _redact_key(CHAT_API_KEY))
raise HTTPException(status_code=502, detail="Cannot connect to chat API")
except HTTPException:
raise
except Exception:
logger.exception("Unexpected error proxying chat")
raise HTTPException(status_code=500, detail="Internal chat proxy error")

View File

@@ -4,13 +4,12 @@ Provides comprehensive analytics, trends, hotspots, and correlations
"""
from fastapi import APIRouter, HTTPException, Query
from datetime import datetime, timedelta
from typing import List, Literal
import random
from pydantic import BaseModel, Field
from typing import Dict, List, Literal
from config import DATA_DIR, RISK_HIGH
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT
from models import (
InsightsResponse,
InsightTrend,
@@ -132,45 +131,45 @@ def generate_correlations(avg_risk: float, risk_variance: float) -> List[Insight
"""Generate correlation factors for insights"""
correlations = [
InsightCorrelation(
factor="temperature",
factor="气温",
correlation=round(-0.45 - 0.1 * (avg_risk - 0.5), 3),
significance="high" if risk_variance > 0.05 else "medium",
description="Temperature vs risk: Lower temps correlate with higher risk",
description="气温与风险呈负相关:低温环境下儿童呼吸道疾病风险显著升高",
impact="negative"
),
InsightCorrelation(
factor="humidity",
factor="湿度",
correlation=round(0.32 + 0.15 * (avg_risk - 0.5), 3),
significance="medium",
description="Humidity vs risk: Higher humidity slightly increases risk",
description="湿度与风险呈弱正相关:高湿度环境下病原体存活时间延长,风险略有增加",
impact="positive"
),
InsightCorrelation(
factor="PM2.5",
correlation=round(0.58 + 0.1 * (avg_risk - 0.5), 3),
significance="high",
description="PM2.5 vs risk: Strong positive correlation with air pollution",
description="PM2.5与风险呈强正相关:细颗粒物浓度升高显著增加儿童呼吸道疾病风险",
impact="positive"
),
InsightCorrelation(
factor="PM10",
correlation=round(0.51 + 0.08 * (avg_risk - 0.5), 3),
significance="high",
description="PM10 vs risk: Moderate positive correlation",
description="PM10与风险呈中等正相关:可吸入颗粒物对儿童呼吸系统有明显影响",
impact="positive"
),
InsightCorrelation(
factor="wind_speed",
factor="风速",
correlation=round(-0.28 - 0.05 * (avg_risk - 0.5), 3),
significance="low",
description="Wind speed vs risk: Higher wind disperses pollutants",
description="风速与风险呈弱负相关:较高风速有利于污染物扩散,降低局部风险",
impact="negative"
),
InsightCorrelation(
factor="population_density",
factor="人口密度",
correlation=round(0.42 + 0.12 * (avg_risk - 0.5), 3),
significance="high",
description="Population density vs risk: Dense areas show higher transmission",
description="人口密度与风险呈正相关:人口密集区域呼吸道疾病传播风险更高",
impact="positive"
),
]
@@ -214,24 +213,24 @@ def generate_demographics(total_grids: int, avg_risk: float) -> List[InsightDemo
def generate_summary(trend: InsightTrend, hotspots: List[InsightHotspot], correlations: List[InsightCorrelation]) -> str:
"""Generate AI-style summary of insights"""
trend_text = "stable"
trend_text = "稳定"
if trend.direction == "up":
trend_text = f"increasing ({trend.avg_change:.1f}% daily)"
trend_text = f"持续上升(日均{trend.avg_change:+.1f}%"
elif trend.direction == "down":
trend_text = f"decreasing ({trend.avg_change:.1f}% daily)"
trend_text = f"持续下降(日均{trend.avg_change:+.1f}%"
hotspot_count = len([h for h in hotspots if h.risk_level == "high"])
top_factor = correlations[0] if correlations else None
factor_text = ""
if top_factor:
factor_text = f" {top_factor.factor} shows the strongest correlation ({top_factor.correlation:.2f})."
factor_text = f"其中{top_factor.factor}的相关性最强(相关系数{top_factor.correlation:.2f})。"
summary = (
f"Over the past {trend.period}, risk levels have been {trend_text}. "
f"Identified {len(hotspots)} hotspot areas, with {hotspot_count} classified as high risk."
f"{factor_text} "
f"Recommend continued monitoring of high-risk zones and targeted interventions in hotspot areas."
f"过去{trend.period}内,风险水平整体{trend_text}"
f"共识别{len(hotspots)}个热点区域,其中{hotspot_count}个为高风险等级。"
f"{factor_text}"
f"建议持续监测高风险区域,针对性加强重点区域干预措施。"
)
return summary
@@ -477,8 +476,160 @@ async def get_insights_cards():
timestamp=now,
))
# ==================== NEW: Daily Cases card ====================
try:
import pandas as pd
cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
if cases_path.exists():
cases_df = pd.read_parquet(cases_path)
latest_case_date = cases_df["date"].max()
latest_cases = cases_df[cases_df["date"] == latest_case_date].copy()
latest_cases["base_district"] = latest_cases["district"].str.replace("", "")
district_daily = latest_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False)
total_daily = int(district_daily.sum())
top_name = district_daily.index[0]
top_val = int(district_daily.iloc[0])
num_districts = len(district_daily)
week_ago = latest_case_date - pd.Timedelta(days=6)
week_cases = cases_df[cases_df["date"] >= week_ago].copy()
week_cases["base_district"] = week_cases["district"].str.replace("", "")
daily_totals = week_cases.groupby("date")["total_cases"].sum()
avg_daily = int(daily_totals.mean())
week_district = week_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False)
week_top_val = int(week_district.iloc[0])
date_str = latest_case_date.strftime("%m月%d")
cards.append(InsightCardItem(
id=f"card-{len(cards) + 1}",
title=f"日病例统计 ({date_str})",
description=(
f"最近统计日({date_str})全市{num_districts}个区共记录{total_daily}例儿童呼吸道疾病病例,"
f"{top_name}{top_val}例为当日最高。近7日日均{avg_daily}例,"
f"{week_district.index[0]}区累计{week_top_val}例居首。"
),
type="warning",
metric="日病例",
metricValue=f"{avg_daily}例/日",
timestamp=now,
))
except Exception:
pass # graceful fallback if case data unavailable
# ==================== NEW: District Risk Comparison card ====================
try:
import pandas as pd
STEP = 1.0 / 1110.0 # 100m grid spacing in degrees
MIN_LAT = 29.969132
MIN_LON = 113.702281
mapping_path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet"
if mapping_path.exists():
# Build grid_id for each geojson grid and merge with district mapping
grids_df = pd.DataFrame(grids)
grids_df["row"] = ((grids_df["latitude"] - MIN_LAT) / STEP).astype(int)
grids_df["col"] = ((grids_df["longitude"] - MIN_LON) / STEP).astype(int)
grids_df["grid_id"] = "r" + grids_df["row"].astype(str) + "_c" + grids_df["col"].astype(str)
mapping = pd.read_parquet(mapping_path)
merged = grids_df.merge(mapping, on="grid_id", how="inner")
if len(merged) > 0:
district_avg = (
merged.groupby("district_name")["risk_value"]
.agg(["mean", "count"])
.sort_values("mean", ascending=False)
)
if len(district_avg) >= 2:
top3 = district_avg.head(3)
top3_parts = [
f"{name}({row['mean']*100:.1f}%)"
for name, row in top3.iterrows()
]
top_name = top3.index[0]
top_mean = top3.iloc[0]["mean"]
cards.append(InsightCardItem(
id=f"card-{len(cards) + 1}",
title="区域风险对比",
description=(
f"基于{len(merged)}个有效网格在{len(district_avg)}个行政区的风险评估,"
f"平均风险最高的三个区为:{''.join(top3_parts)}"
f"{top_name}风险均值({top_mean*100:.1f}%)高于全市均值({avg_risk*100:.1f}%),建议重点巡查。"
),
type="info",
metric="最高风险区",
metricValue=f"{top_name} {top_mean*100:.1f}%",
timestamp=now,
))
except Exception:
pass # graceful fallback if mapping data unavailable
# ==================== NEW: Weather Impact card ====================
try:
import pandas as pd
weather_path = PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet"
if weather_path.exists():
weather_df = pd.read_parquet(weather_path)
daily_wx = weather_df.groupby("date").agg(
AQI=("AQI", "mean"), PM25=("PM25", "mean"), PM10=("PM10", "mean"),
).reset_index()
daily_wx["month"] = daily_wx["date"].dt.month
winter_wx = daily_wx[daily_wx["month"].isin([12, 1, 2])]
summer_wx = daily_wx[daily_wx["month"].isin([6, 7, 8])]
avg_aqi = daily_wx["AQI"].mean()
avg_pm25 = daily_wx["PM25"].mean()
winter_pm25 = winter_wx["PM25"].mean()
summer_pm25 = summer_wx["PM25"].mean()
cards.append(InsightCardItem(
id=f"card-{len(cards) + 1}",
title="空气质量与呼吸健康关联",
description=(
f"武汉市年均PM2.5浓度约{avg_pm25:.0f}μg/m³AQI均值{avg_aqi:.0f}"
f"PM2.5与儿童呼吸风险呈正相关(r=0.58)。"
f"冬季PM2.5浓度({winter_pm25:.0f}μg/m³)较夏季({summer_pm25:.0f}μg/m³)"
f"升高{(winter_pm25/summer_pm25-1)*100:.0f}%,提示冬季空气污染加剧需加强呼吸健康防护。"
),
type="info",
metric="PM2.5年均",
metricValue=f"{avg_pm25:.0f} μg/m³",
timestamp=now,
))
except Exception:
pass # graceful fallback if weather data unavailable
# ==================== NEW: Seasonal Pattern card ====================
current_month = datetime.now().month
if current_month in [12, 1, 2]:
season = "冬季"
season_info = "冬季为儿童呼吸道疾病高发期。历史数据显示冬季门诊量较夏季增加30%-50%PM2.5浓度可达夏季的1.5-2倍。建议加强室内空气净化减少重污染天气户外活动。"
elif current_month in [3, 4, 5]:
season = "春季"
season_info = "春季花粉浓度上升,可能诱发过敏性呼吸道疾病。历史数据表明春季门诊量较为平稳,但需注意过敏原叠加空气污染的双重风险。"
elif current_month in [6, 7, 8]:
season = "夏季"
season_info = "夏季臭氧污染上升,高温天气影响儿童户外活动。门诊量通常低于冬季,但臭氧-温度复合效应仍需关注。建议关注AQI中的O3分指数。"
else:
season = "秋季"
season_info = "秋季气温波动大儿童呼吸道疾病发病率逐步上升。PM2.5浓度开始回升,建议提前部署冬季防控准备,加强学校等场所通风监测。"
cards.append(InsightCardItem(
id=f"card-{len(cards) + 1}",
title=f"季节性风险提示 ({season})",
description=f"当前{current_month}月处于{season}{season_info}",
type="info",
metric="当前季节",
metricValue=season,
timestamp=now,
))
# Info cards from correlations
for corr in correlations[:3]:
for corr in correlations[:2]:
sign = "+" if corr.correlation > 0 else ""
cards.append(InsightCardItem(
id=f"card-{len(cards) + 1}",

View File

@@ -0,0 +1,185 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { MessageSquare, X, Send, RefreshCw, Loader2 } from 'lucide-react';
import { chatApi } from '@/services/api';
interface Message {
role: 'user' | 'assistant';
content: string;
}
export function ChatBot() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const scrollToBottom = useCallback(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, isLoading, scrollToBottom]);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
const handleSend = useCallback(async () => {
const trimmed = input.trim();
if (!trimmed || isLoading) return;
const userMessage: Message = { role: 'user', content: trimmed };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setInput('');
setError(null);
setIsLoading(true);
try {
const data = await chatApi.sendMessage(
updatedMessages.map((m) => ({ role: m.role, content: m.content }))
);
setMessages((prev) => [...prev, { role: 'assistant', content: data.reply }]);
} catch (err: any) {
const errMsg = err?.response?.data?.detail || err?.message || '请求失败,请稍后重试';
setError(errMsg);
} finally {
setIsLoading(false);
}
}, [input, isLoading, messages]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend]
);
const handleRetry = useCallback(() => {
setError(null);
handleSend();
}, [handleSend]);
return (
<>
{/* Float toggle button */}
<button
onClick={() => setIsOpen((prev) => !prev)}
className={`fixed bottom-5 right-5 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary shadow-lg transition-all hover:bg-primary-light ${
isOpen ? 'scale-0 opacity-0' : 'scale-100 opacity-100'
}`}
aria-label={isOpen ? '关闭聊天' : '打开聊天'}
>
<MessageSquare className="h-5 w-5 text-white" />
</button>
{/* Chat panel */}
{isOpen && (
<div className="fixed bottom-20 right-5 z-50 flex w-[380px] flex-col rounded-xl border border-border bg-bg-card shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between rounded-t-xl bg-primary p-3 text-white">
<h3 className="text-[14px] font-semibold">AI </h3>
<button
onClick={() => setIsOpen(false)}
className="rounded p-1 transition-colors hover:bg-white/20"
aria-label="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Messages area */}
<div
ref={scrollRef}
className="flex flex-col gap-3 overflow-y-auto p-4"
style={{ height: '420px' }}
>
{messages.length === 0 && !error && (
<div className="flex flex-1 flex-col items-center justify-center py-12 text-center">
<MessageSquare className="mb-3 h-10 w-10 text-text-muted" />
<p className="text-[13px] text-text-muted">
</p>
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2 text-[13px] leading-relaxed ${
msg.role === 'user'
? 'rounded-br-sm bg-primary text-white'
: 'rounded-bl-sm bg-bg-page text-text-primary'
}`}
>
{msg.content}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="flex items-center gap-2 rounded-2xl rounded-bl-sm bg-bg-page px-4 py-3">
<Loader2 className="h-4 w-4 animate-spin text-text-muted" />
<span className="text-[12px] text-text-muted">...</span>
</div>
</div>
)}
{error && (
<div className="flex flex-col items-start gap-2 rounded-lg border border-danger/20 bg-danger-light px-4 py-3">
<span className="text-[13px] text-danger">{error}</span>
<button
onClick={handleRetry}
className="flex items-center gap-1 rounded px-2.5 py-1 text-[12px] font-medium text-danger transition-colors hover:bg-danger/10"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{/* Input area */}
<div className="flex gap-2 border-t border-border p-3">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入您的问题..."
disabled={isLoading}
className="flex-1 rounded-lg border border-border bg-bg-page px-3 py-2 text-[13px] text-text-primary placeholder-text-muted outline-none transition-colors focus:border-primary disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={isLoading || !input.trim()}
className="flex items-center justify-center rounded-lg bg-primary px-4 text-[13px] font-medium text-white transition-colors hover:bg-primary-light disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
</div>
)}
</>
);
}

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useAnalysisStore } from '@/stores/analysisStore';
import { ErrorBanner } from '@/components/ErrorBanner';
import { ChatBot } from '@/components/ChatBot';
import {
Lightbulb,
AlertTriangle,
@@ -193,6 +194,8 @@ export function Insights() {
<p className="text-text-secondary"></p>
</div>
)}
<ChatBot />
</div>
);
}

View File

@@ -106,6 +106,17 @@ export async function cachedGet<T>(url: string, params?: Record<string, any>): P
return promise;
}
export async function cachedPost<T>(url: string, body: unknown): Promise<T> {
const resp = await api.post<T>(url, body);
return resp.data;
}
export const chatApi = {
sendMessage: (
messages: Array<{ role: string; content: string }>
): Promise<{ reply: string; model: string }> => cachedPost('/chat', { messages }),
};
export const riskApi = {
getCurrentRiskMap: (): Promise<RiskMapResponse> => cachedGet('/risk/current'),