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

@@ -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'),