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:
185
frontend/src/components/ChatBot.tsx
Normal file
185
frontend/src/components/ChatBot.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user