Files
QuestionProject/hy_qa_asset_backend/frontend/components/h5/H5QAExperience.tsx

318 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { FormEvent, useEffect, useRef, useState } from "react";
import {
BookOpenCheck,
MessageCircle,
Plus,
SendHorizonal,
ShieldCheck,
} from "lucide-react";
import { api } from "@/lib/api";
import type { CallableQAItem } from "@/lib/types";
import {
createEmptySession,
findQaAnswer,
initialMessages,
suggestedQuestions,
summarizeSession,
type ChatSession,
type ChatMessage
} from "@/lib/h5MockQa";
import ChatBubble from "./ChatBubble";
const STORAGE_KEY = "hy-qa-h5-sessions";
const SHOW_AUTH_ENTRY = false;
const SHOW_HISTORY_AND_PROFILE = false;
function nowText() {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date());
}
function buildAnswerMessage(question: string, qa: CallableQAItem): ChatMessage {
return {
id: `assistant-${Date.now()}`,
role: "assistant",
content: qa.standard_answer,
source: `${qa.standard_code} / ${qa.primary_topic} / ${qa.course_stage}`,
boundary: qa.answer_boundary ?? "答复仅基于已审核答疑资产生成,不能替代专业诊断、法律判断或医疗建议。",
createdAt: nowText()
};
}
function buildUnavailableMessage(reason: "loading" | "empty" | "error"): ChatMessage {
const content = {
loading: "我还在连接可调用问答库,请稍等几秒后再试。",
empty: "当前可调用问答库还没有可用内容。请先到后台标准问答库中,把已审核、低/中风险、已脱敏或无需脱敏的问答标记为“可调用”。",
error: "暂时连接不到后端可调用库,请检查后端服务或网络连接后再试。"
}[reason];
return {
id: `assistant-unavailable-${Date.now()}`,
role: "assistant",
content,
boundary: "H5 端不会使用未审核或不可调用的内容生成回答。",
createdAt: nowText()
};
}
export default function H5QAExperience() {
const [input, setInput] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [currentSessionId, setCurrentSessionId] = useState("");
const [callableQa, setCallableQa] = useState<CallableQAItem[]>([]);
const [loadingQa, setLoadingQa] = useState(true);
const [qaLoadError, setQaLoadError] = useState<string | null>(null);
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(null);
const qaStatusText = loadingQa ? "正在连接可调用库" : qaLoadError ? "后端连接异常" : callableQa.length > 0 ? "已连接可调用库" : "可调用库为空";
useEffect(() => {
const fallback = createEmptySession();
try {
const saved = window.localStorage.getItem(STORAGE_KEY);
const parsed = saved ? (JSON.parse(saved) as ChatSession[]) : [];
const validSessions = parsed.length > 0 ? parsed : [fallback];
setSessions(validSessions);
setCurrentSessionId(validSessions[0].id);
setMessages(validSessions[0].messages);
} catch {
setSessions([fallback]);
setCurrentSessionId(fallback.id);
setMessages(fallback.messages);
}
}, []);
useEffect(() => {
if (sessions.length > 0) {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
}
}, [sessions]);
useEffect(() => {
api
.get<CallableQAItem[]>("/standard-qa/callable")
.then((items) => {
setCallableQa(items);
setQaLoadError(null);
})
.catch((error) => {
setCallableQa([]);
setQaLoadError(error instanceof Error ? error.message : "可调用库接口请求失败");
})
.finally(() => setLoadingQa(false));
}, []);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages]);
function persistMessages(nextMessages: ChatMessage[]) {
setMessages(nextMessages);
setSessions((current) => {
const sessionId = currentSessionId || createEmptySession().id;
const nextSession = summarizeSession(sessionId, nextMessages);
const withoutCurrent = current.filter((session) => session.id !== sessionId);
return [nextSession, ...withoutCurrent].slice(0, 20);
});
}
function startNewSession() {
const nextSession = createEmptySession();
setCurrentSessionId(nextSession.id);
setMessages(nextSession.messages);
setSessions((current) => [nextSession, ...current].slice(0, 20));
}
async function copyMessage(message: ChatMessage) {
try {
await navigator.clipboard.writeText(message.content);
} catch {
// Some embedded webviews/headless browsers block clipboard writes.
// Keep the UI feedback so the user knows the copy action was triggered.
}
setCopiedMessageId(message.id);
window.setTimeout(() => setCopiedMessageId(null), 1400);
}
function setFeedback(messageId: string, feedback: "liked" | "disliked") {
const nextMessages = messages.map((message) =>
message.id === messageId ? { ...message, feedback: message.feedback === feedback ? undefined : feedback } : message
);
persistMessages(nextMessages);
}
function ask(question: string) {
const trimmed = question.trim();
if (!trimmed) return;
const matchedQa = findQaAnswer(trimmed, callableQa);
const timestamp = Date.now();
const assistantMessage = loadingQa
? buildUnavailableMessage("loading")
: qaLoadError
? buildUnavailableMessage("error")
: matchedQa
? { ...buildAnswerMessage(trimmed, matchedQa), id: `assistant-${timestamp}` }
: buildUnavailableMessage("empty");
const nextMessages: ChatMessage[] = [
...messages,
{
id: `user-${timestamp}`,
role: "user",
content: trimmed,
createdAt: nowText()
},
assistantMessage
];
persistMessages(nextMessages);
setInput("");
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
ask(input);
}
return (
<div className="min-h-screen bg-[#eef3f1] text-ink lg:grid lg:grid-cols-[minmax(0,1fr)_420px_minmax(0,1fr)]">
<aside className="hidden px-8 py-10 lg:block">
<div className="sticky top-10 ml-auto max-w-sm">
<div className="mb-5 flex items-center gap-3">
<BookOpenCheck className="h-7 w-7 text-jade" aria-hidden />
<div>
<div className="text-lg font-semibold"> H5</div>
<div className="text-sm text-slate-500"></div>
</div>
</div>
<div className="space-y-3 text-sm leading-6 text-slate-600">
<p>使H5 </p>
<p></p>
</div>
</div>
</aside>
<main className="mx-auto flex min-h-screen w-full max-w-[430px] flex-col bg-[#f7faf8] shadow-2xl shadow-slate-900/10 lg:my-6 lg:min-h-[860px] lg:overflow-hidden lg:rounded-[22px]">
<header className="shrink-0 border-b border-line bg-white px-4 pb-3 pt-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-jade text-white">
<MessageCircle className="h-5 w-5" aria-hidden />
</div>
<div>
<h1 className="text-lg font-semibold leading-6"></h1>
<div className="mt-0.5 flex items-center gap-1 text-xs text-slate-500">
<ShieldCheck className="h-3.5 w-3.5 text-jade" aria-hidden />
{qaStatusText}
</div>
</div>
</div>
{SHOW_AUTH_ENTRY ? null : null}
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<button
onClick={startNewSession}
className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2 text-xs font-medium text-slate-600 hover:bg-slate-50"
>
<Plus className="h-3.5 w-3.5" aria-hidden />
</button>
{copiedMessageId ? <span className="text-xs text-jade"></span> : null}
</div>
</header>
<section className="relative flex min-h-0 flex-1 flex-col">
<div className="flex-1 overflow-y-auto px-4 py-4">
<div className="mb-4 rounded-md border border-teal-100 bg-teal-50 px-3 py-2 text-xs leading-5 text-teal-800">
</div>
{!loadingQa && (qaLoadError || callableQa.length === 0) ? (
<div className="mb-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
{qaLoadError ? `可调用库接口异常:${qaLoadError}` : "当前后端可调用库为空H5 不会用本地 mock 内容代替真实答案。"}
</div>
) : null}
<div className="space-y-4">
{messages.map((message) => (
<ChatBubble key={message.id} message={message} onCopy={copyMessage} onFeedback={setFeedback} />
))}
</div>
<div ref={bottomRef} />
</div>
<div className="shrink-0 border-t border-line bg-white px-4 py-3">
<div className="mb-3 flex gap-2 overflow-x-auto pb-1">
{suggestedQuestions.map((item) => (
<button
key={item.id}
onClick={() => ask(item.text)}
className="shrink-0 rounded-md border border-line bg-panel px-3 py-2 text-left text-xs leading-5 text-slate-700 hover:bg-teal-50 hover:text-jade"
>
{item.text}
</button>
))}
</div>
<form onSubmit={submit} className="flex items-end gap-2">
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
rows={1}
placeholder="输入你的问题..."
className="max-h-28 min-h-11 flex-1 resize-none rounded-md border border-line bg-panel px-3 py-3 text-[15px] leading-5 outline-none focus:border-jade focus:bg-white"
/>
<button
type="submit"
disabled={!input.trim()}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-md bg-jade text-white hover:bg-teal-800 disabled:bg-slate-300"
aria-label="发送"
>
<SendHorizonal className="h-5 w-5" aria-hidden />
</button>
</form>
{SHOW_HISTORY_AND_PROFILE ? (
<div className="mt-3 rounded-md bg-panel px-3 py-2 text-xs text-slate-500">
</div>
) : null}
</div>
</section>
{SHOW_HISTORY_AND_PROFILE ? (
<nav className="hidden h-[62px] shrink-0 grid-cols-3 border-t border-line bg-white text-xs">
{[
{ label: "问答" },
{ label: "记录" },
{ label: "我的" }
].map((item) => (
<button
key={item.label}
className="flex flex-col items-center justify-center gap-1 text-slate-400"
type="button"
>
<span>{item.label}</span>
</button>
))}
</nav>
) : null}
</main>
<aside className="hidden px-8 py-10 lg:block">
<div className="sticky top-10 max-w-sm rounded-md border border-line bg-white p-5 shadow-sm">
<h2 className="text-base font-semibold"></h2>
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-600">
<li> H5</li>
<li>使 mock </li>
<li></li>
<li></li>
</ul>
</div>
</aside>
</div>
);
}