eab91174db
Co-Authored-By: Claude <noreply@anthropic.com>
146 lines
4.7 KiB
TypeScript
146 lines
4.7 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { useLocation, useNavigate } from 'react-router-dom';
|
||
import { ArrowLeft, AlertTriangle } from 'lucide-react';
|
||
import { api, ApiError } from '../api/client';
|
||
import type { KnowledgeCitation } from '../api/types';
|
||
import './ChatPage.css';
|
||
|
||
interface Message {
|
||
id: string;
|
||
role: 'user' | 'assistant';
|
||
text: string;
|
||
grounded?: boolean;
|
||
citations?: KnowledgeCitation[];
|
||
}
|
||
|
||
const AUTHORITY_LABEL: Record<string, string> = {
|
||
authoritative: '权威',
|
||
reference: '参考',
|
||
self: '自建',
|
||
};
|
||
|
||
const WELCOME: Message = {
|
||
id: 'welcome',
|
||
role: 'assistant',
|
||
text: '你好呀~我是你的孕期助手。关于孕期饮食、监测、不适或注意事项,都可以问我。我会附上知识来源,遇到拿不准的会建议你及时就医。',
|
||
};
|
||
|
||
export function ChatPage(): JSX.Element {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const initialQuestion = (location.state as { initialQuestion?: string } | null)?.initialQuestion;
|
||
|
||
const [messages, setMessages] = useState<Message[]>([WELCOME]);
|
||
const [input, setInput] = useState('');
|
||
const [sending, setSending] = useState(false);
|
||
const listRef = useRef<HTMLDivElement>(null);
|
||
const askedInitial = useRef(false);
|
||
|
||
async function send(question: string): Promise<void> {
|
||
const q = question.trim();
|
||
if (!q || sending) return;
|
||
const userMsg: Message = { id: `u-${Date.now()}`, role: 'user', text: q };
|
||
setMessages((prev) => [...prev, userMsg]);
|
||
setInput('');
|
||
setSending(true);
|
||
try {
|
||
const answer = await api.ask(q);
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: `a-${Date.now()}`,
|
||
role: 'assistant',
|
||
text: answer.answer,
|
||
grounded: answer.grounded,
|
||
citations: answer.citations,
|
||
},
|
||
]);
|
||
} catch (err) {
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: `e-${Date.now()}`,
|
||
role: 'assistant',
|
||
text: err instanceof ApiError ? err.message : '抱歉,我暂时没能回答,请稍后再试。',
|
||
},
|
||
]);
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (initialQuestion && !askedInitial.current) {
|
||
askedInitial.current = true;
|
||
void send(initialQuestion);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [initialQuestion]);
|
||
|
||
useEffect(() => {
|
||
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' });
|
||
}, [messages]);
|
||
|
||
return (
|
||
<div className="chat">
|
||
<header className="chat__header">
|
||
<button className="chat__back" onClick={() => navigate(-1)} type="button" aria-label="返回">
|
||
<ArrowLeft size={22} strokeWidth={1.75} />
|
||
</button>
|
||
<span>孕期助手</span>
|
||
</header>
|
||
|
||
<div className="chat__list" ref={listRef}>
|
||
{messages.map((m) => (
|
||
<div key={m.id} className={`chat__row chat__row--${m.role}`}>
|
||
<div className={`chat__bubble chat__bubble--${m.role}`}>
|
||
<p>{m.text}</p>
|
||
{m.role === 'assistant' && m.grounded === false && (
|
||
<p className="chat__nogrounded">
|
||
<AlertTriangle size={15} strokeWidth={1.9} /> 这个问题我暂时没有权威依据,建议你咨询医生或管理师。
|
||
</p>
|
||
)}
|
||
{m.citations && m.citations.length > 0 && (
|
||
<div className="chat__citations">
|
||
<p className="chat__citations-title">来源</p>
|
||
{m.citations.map((c) => (
|
||
<div key={c.id} className="chat__citation">
|
||
<span className="badge badge-ok">{AUTHORITY_LABEL[c.authority] ?? c.authority}</span>
|
||
<span className="chat__citation-text">
|
||
{c.title}
|
||
<span className="muted"> · {c.source}</span>
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{sending && (
|
||
<div className="chat__row chat__row--assistant">
|
||
<div className="chat__bubble chat__bubble--assistant chat__typing">正在思考…</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<form
|
||
className="chat__input"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
void send(input);
|
||
}}
|
||
>
|
||
<input
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
placeholder="问问孕期助手…"
|
||
/>
|
||
<button className="btn btn-primary" type="submit" disabled={sending || !input.trim()}>
|
||
发送
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|