318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
"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>
|
||
);
|
||
}
|