Add mobile H5 QA experience
This commit is contained in:
5
hy_qa_asset_backend/frontend/app/h5/page.tsx
Normal file
5
hy_qa_asset_backend/frontend/app/h5/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import H5QAExperience from "@/components/h5/H5QAExperience";
|
||||||
|
|
||||||
|
export default function H5Page() {
|
||||||
|
return <H5QAExperience />;
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import Sidebar from "./Sidebar";
|
import Sidebar from "./Sidebar";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: ReactNode }) {
|
export default function Layout({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
if (pathname.startsWith("/h5")) {
|
||||||
|
return <div className="min-h-screen bg-[#eef3f1]">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#f5f7fa]">
|
<div className="min-h-screen bg-[#f5f7fa]">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
DatabaseZap,
|
DatabaseZap,
|
||||||
Gauge,
|
Gauge,
|
||||||
|
MessageCircle,
|
||||||
Library,
|
Library,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -21,6 +22,7 @@ const navItems = [
|
|||||||
{ href: "/high-risk", label: "高风险", icon: ShieldAlert },
|
{ href: "/high-risk", label: "高风险", icon: ShieldAlert },
|
||||||
{ href: "/standard-qa", label: "标准库", icon: Library },
|
{ href: "/standard-qa", label: "标准库", icon: Library },
|
||||||
{ href: "/callable-qa", label: "可调用库", icon: DatabaseZap },
|
{ href: "/callable-qa", label: "可调用库", icon: DatabaseZap },
|
||||||
|
{ href: "/h5", label: "H5 问答", icon: MessageCircle },
|
||||||
{ href: "/logs", label: "运行日志", icon: ListChecks },
|
{ href: "/logs", label: "运行日志", icon: ListChecks },
|
||||||
{ href: "/settings", label: "系统设置", icon: Settings }
|
{ href: "/settings", label: "系统设置", icon: Settings }
|
||||||
];
|
];
|
||||||
|
|||||||
41
hy_qa_asset_backend/frontend/components/h5/ChatBubble.tsx
Normal file
41
hy_qa_asset_backend/frontend/components/h5/ChatBubble.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Bot, UserRound } from "lucide-react";
|
||||||
|
import type { ChatMessage } from "@/lib/h5MockQa";
|
||||||
|
|
||||||
|
export default function ChatBubble({ message }: { message: ChatMessage }) {
|
||||||
|
const isUser = message.role === "user";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex gap-2 ${isUser ? "justify-end" : "justify-start"}`}>
|
||||||
|
{!isUser ? (
|
||||||
|
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-jade text-white">
|
||||||
|
<Bot className="h-4 w-4" aria-hidden />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={`max-w-[82%] ${isUser ? "items-end" : "items-start"}`}>
|
||||||
|
<div
|
||||||
|
className={`rounded-[14px] px-4 py-3 text-[15px] leading-7 shadow-sm ${
|
||||||
|
isUser ? "rounded-tr-md bg-jade text-white" : "rounded-tl-md border border-line bg-white text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="whitespace-pre-wrap">{message.content}</div>
|
||||||
|
{message.source ? (
|
||||||
|
<div className={`mt-3 border-t pt-2 text-xs ${isUser ? "border-white/25 text-teal-50" : "border-line text-slate-500"}`}>
|
||||||
|
来源:{message.source}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{message.boundary ? (
|
||||||
|
<div className="mt-3 rounded-md bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">{message.boundary}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={`mt-1 text-xs text-slate-400 ${isUser ? "text-right" : "text-left"}`}>{message.createdAt}</div>
|
||||||
|
</div>
|
||||||
|
{isUser ? (
|
||||||
|
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-slate-200 text-slate-600">
|
||||||
|
<UserRound className="h-4 w-4" aria-hidden />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
hy_qa_asset_backend/frontend/components/h5/H5QAExperience.tsx
Normal file
255
hy_qa_asset_backend/frontend/components/h5/H5QAExperience.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { BookOpenCheck, Clock3, Home, MessageCircle, SendHorizonal, Settings, ShieldCheck, UserRound } from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { CallableQAItem } from "@/lib/types";
|
||||||
|
import {
|
||||||
|
findQaAnswer,
|
||||||
|
initialMessages,
|
||||||
|
mockCallableQa,
|
||||||
|
suggestedQuestions,
|
||||||
|
type ChatMessage
|
||||||
|
} from "@/lib/h5MockQa";
|
||||||
|
import ChatBubble from "./ChatBubble";
|
||||||
|
import MockAuthPanel, { type MockUser } from "./MockAuthPanel";
|
||||||
|
|
||||||
|
type MobileTab = "qa" | "history" | "me";
|
||||||
|
|
||||||
|
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 EmptyState({ tab }: { tab: MobileTab }) {
|
||||||
|
const copy = {
|
||||||
|
history: "登录后这里会展示你的历史提问记录。当前先用本机 mock 会话演示。",
|
||||||
|
me: "账号、学习阶段、隐私设置后续会接真实接口;现在先预留微信授权入口。",
|
||||||
|
qa: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tab === "qa") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center px-6 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-md bg-teal-50 text-jade">
|
||||||
|
{tab === "history" ? <Clock3 className="h-6 w-6" aria-hidden /> : <UserRound className="h-6 w-6" aria-hidden />}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6 text-slate-500">{copy[tab]}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function H5QAExperience() {
|
||||||
|
const [activeTab, setActiveTab] = useState<MobileTab>("qa");
|
||||||
|
const [authOpen, setAuthOpen] = useState(false);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
||||||
|
const [callableQa, setCallableQa] = useState<CallableQAItem[]>([]);
|
||||||
|
const [loadingQa, setLoadingQa] = useState(true);
|
||||||
|
const [user, setUser] = useState<MockUser | null>(null);
|
||||||
|
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const qaSource = useMemo(() => (callableQa.length > 0 ? callableQa : mockCallableQa), [callableQa]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.get<CallableQAItem[]>("/standard-qa/callable")
|
||||||
|
.then(setCallableQa)
|
||||||
|
.catch(() => setCallableQa([]))
|
||||||
|
.finally(() => setLoadingQa(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
|
}, [messages, activeTab]);
|
||||||
|
|
||||||
|
function mockLogin(mode: MockUser["mode"]) {
|
||||||
|
setUser({
|
||||||
|
mode,
|
||||||
|
name: mode === "login" ? "大本营学员" : "新学员",
|
||||||
|
phone: "138****2026"
|
||||||
|
});
|
||||||
|
setAuthOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ask(question: string) {
|
||||||
|
const trimmed = question.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
const matchedQa = findQaAnswer(trimmed, qaSource);
|
||||||
|
setMessages((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `user-${Date.now()}`,
|
||||||
|
role: "user",
|
||||||
|
content: trimmed,
|
||||||
|
createdAt: nowText()
|
||||||
|
},
|
||||||
|
buildAnswerMessage(trimmed, matchedQa)
|
||||||
|
]);
|
||||||
|
setInput("");
|
||||||
|
setActiveTab("qa");
|
||||||
|
}
|
||||||
|
|
||||||
|
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>当前页面只做前端加法:登录注册为 mock 状态,问答优先读取已审核可调用库,没有数据时使用本地演示问答。</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 />
|
||||||
|
{loadingQa ? "正在检查可调用库" : callableQa.length > 0 ? "已连接可调用库" : "演示模式"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setAuthOpen(true)}
|
||||||
|
className="flex h-10 min-w-10 items-center justify-center rounded-md border border-line bg-white px-3 text-sm font-medium hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{user ? user.name.slice(0, 2) : <UserRound className="h-5 w-5" aria-hidden />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="relative flex min-h-0 flex-1 flex-col">
|
||||||
|
{activeTab === "qa" ? (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<ChatBubble key={message.id} message={message} />
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState tab={activeTab} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MockAuthPanel
|
||||||
|
open={authOpen}
|
||||||
|
user={user}
|
||||||
|
onClose={() => setAuthOpen(false)}
|
||||||
|
onMockLogin={mockLogin}
|
||||||
|
onLogout={() => {
|
||||||
|
setUser(null);
|
||||||
|
setAuthOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav className="grid h-[62px] shrink-0 grid-cols-3 border-t border-line bg-white text-xs">
|
||||||
|
{[
|
||||||
|
{ key: "qa" as const, label: "问答", icon: Home },
|
||||||
|
{ key: "history" as const, label: "记录", icon: Clock3 },
|
||||||
|
{ key: "me" as const, label: "我的", icon: Settings }
|
||||||
|
].map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = activeTab === item.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => setActiveTab(item.key)}
|
||||||
|
className={`flex flex-col items-center justify-center gap-1 ${active ? "text-jade" : "text-slate-400"}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" aria-hidden />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</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>登录注册:替换 mock 面板,接微信授权或手机号验证码。</li>
|
||||||
|
<li>问答检索:从当前可调用库接口升级为语义检索或智能体接口。</li>
|
||||||
|
<li>历史记录:按用户维度保存问题、答案、反馈和风险提示。</li>
|
||||||
|
<li>安全策略:继续只允许调用已审核、可调用、低/中风险内容。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
hy_qa_asset_backend/frontend/components/h5/MockAuthPanel.tsx
Normal file
79
hy_qa_asset_backend/frontend/components/h5/MockAuthPanel.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LogIn, ShieldCheck, UserPlus, X } from "lucide-react";
|
||||||
|
|
||||||
|
export type MockUser = {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
mode: "login" | "register";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MockAuthPanel({
|
||||||
|
open,
|
||||||
|
user,
|
||||||
|
onClose,
|
||||||
|
onMockLogin,
|
||||||
|
onLogout
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
user: MockUser | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onMockLogin: (mode: MockUser["mode"]) => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
}) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-30 flex items-end bg-slate-950/35 px-3 pb-3">
|
||||||
|
<section className="w-full rounded-t-[18px] rounded-b-md bg-white p-4 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-ink">账号入口</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">先用 mock 登录,后续可替换为微信授权或手机号登录。</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="flex h-9 w-9 items-center justify-center rounded-md hover:bg-slate-100" aria-label="关闭">
|
||||||
|
<X className="h-5 w-5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user ? (
|
||||||
|
<div className="rounded-md border border-line bg-panel p-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">
|
||||||
|
<ShieldCheck className="h-5 w-5" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-ink">{user.name}</div>
|
||||||
|
<div className="text-sm text-slate-500">{user.phone}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onLogout} className="mt-3 h-10 w-full rounded-md border border-line text-sm font-medium hover:bg-white">
|
||||||
|
退出 mock 账号
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onMockLogin("login")}
|
||||||
|
className="flex h-11 items-center justify-center gap-2 rounded-md bg-jade text-sm font-medium text-white hover:bg-teal-800"
|
||||||
|
>
|
||||||
|
<LogIn className="h-4 w-4" aria-hidden />
|
||||||
|
mock 登录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onMockLogin("register")}
|
||||||
|
className="flex h-11 items-center justify-center gap-2 rounded-md border border-line text-sm font-medium text-ink hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<UserPlus className="h-4 w-4" aria-hidden />
|
||||||
|
mock 注册并进入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-md bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
|
||||||
|
这里暂不写入后端,也不保存敏感信息;后续接微信小程序时可把这个面板替换成授权弹窗。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
hy_qa_asset_backend/frontend/lib/h5MockQa.ts
Normal file
119
hy_qa_asset_backend/frontend/lib/h5MockQa.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { CallableQAItem } from "./types";
|
||||||
|
|
||||||
|
export type ChatRole = "assistant" | "user";
|
||||||
|
|
||||||
|
export type ChatMessage = {
|
||||||
|
id: string;
|
||||||
|
role: ChatRole;
|
||||||
|
content: string;
|
||||||
|
source?: string;
|
||||||
|
boundary?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SuggestedQuestion = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const suggestedQuestions: SuggestedQuestion[] = [
|
||||||
|
{
|
||||||
|
id: "parent-child-emotion",
|
||||||
|
text: "孩子一写作业就发脾气,我怎么接住他的情绪?",
|
||||||
|
tags: ["亲子关系", "情绪"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "self-growth",
|
||||||
|
text: "我知道要行动,但总是拖延,应该先从哪里开始?",
|
||||||
|
tags: ["个人成长", "行动力"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "marriage-talk",
|
||||||
|
text: "伴侣不愿意沟通,我怎么表达才不容易吵起来?",
|
||||||
|
tags: ["婚姻关系", "沟通"]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const initialMessages: ChatMessage[] = [
|
||||||
|
{
|
||||||
|
id: "welcome",
|
||||||
|
role: "assistant",
|
||||||
|
content:
|
||||||
|
"你好,我是千问千答助手。你可以把最近遇到的亲子、关系、情绪或行动问题发给我,我会优先基于已审核的答疑资产给你一个可落地的回应。",
|
||||||
|
boundary: "当前为 H5 体验版,登录与个性化记录暂用 mock 数据。",
|
||||||
|
createdAt: "09:30"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockCallableQa: CallableQAItem[] = [
|
||||||
|
{
|
||||||
|
id: 9001,
|
||||||
|
standard_code: "MOCK-001",
|
||||||
|
standard_question: "孩子一写作业就发脾气,家长应该怎么办?",
|
||||||
|
standard_answer:
|
||||||
|
"先不要急着纠正行为,建议先区分两件事:孩子是在逃避任务,还是被情绪淹没了。可以先用一句很短的话接住情绪,比如“我看到你现在很烦,先停两分钟”。等情绪下降后,再把任务拆小,只约定下一步,而不是一次性要求全部完成。",
|
||||||
|
similar_questions: ["孩子写作业崩溃怎么办", "孩子学习时情绪大怎么办"],
|
||||||
|
primary_topic: "亲子关系",
|
||||||
|
problem_tags: ["情绪接纳", "作业冲突", "边界"],
|
||||||
|
course_stage: "大本营综合",
|
||||||
|
audience_tags: ["家长"],
|
||||||
|
answer_boundary: "不替代心理诊断;如果孩子长期极端失控,建议寻求专业支持。",
|
||||||
|
forbidden_expressions: ["保证有效", "一定改变"],
|
||||||
|
source_raw_qa_id: 9001,
|
||||||
|
session_id: 9001
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9002,
|
||||||
|
standard_code: "MOCK-002",
|
||||||
|
standard_question: "知道要行动但总是拖延,应该怎么开始?",
|
||||||
|
standard_answer:
|
||||||
|
"拖延时不要先给自己贴标签,可以先把目标缩小到“今天能完成的最小动作”。比如不是要求自己完整学习一小时,而是先打开资料、看 5 分钟、记一句话。行动力常常不是靠情绪准备好了才开始,而是靠一个足够小的开始把状态带起来。",
|
||||||
|
similar_questions: ["总是拖延怎么办", "行动力差怎么调整"],
|
||||||
|
primary_topic: "个人成长",
|
||||||
|
problem_tags: ["行动力", "拖延", "自我要求"],
|
||||||
|
course_stage: "觉知",
|
||||||
|
audience_tags: ["学员"],
|
||||||
|
answer_boundary: "只提供学习和自我观察建议,不承诺课程效果。",
|
||||||
|
forbidden_expressions: ["一定改变", "保证有效"],
|
||||||
|
source_raw_qa_id: 9002,
|
||||||
|
session_id: 9002
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9003,
|
||||||
|
standard_code: "MOCK-003",
|
||||||
|
standard_question: "伴侣不愿意沟通时,如何降低冲突?",
|
||||||
|
standard_answer:
|
||||||
|
"可以先把“让对方马上理解我”调整成“先让对话能继续”。表达时尽量少用评判句,多用事实加感受,比如“刚才你直接走开,我有点失落,也有点不知道怎么继续”。如果对方暂时不想谈,可以约一个明确时间,而不是追着当场解决。",
|
||||||
|
similar_questions: ["伴侣拒绝沟通怎么办", "夫妻沟通容易吵架怎么办"],
|
||||||
|
primary_topic: "婚姻关系",
|
||||||
|
problem_tags: ["沟通", "冲突降温", "关系边界"],
|
||||||
|
course_stage: "原生",
|
||||||
|
audience_tags: ["伴侣关系"],
|
||||||
|
answer_boundary: "不做法律判断;涉及暴力或安全风险时应优先保护自身安全。",
|
||||||
|
forbidden_expressions: ["法律上必然", "一定改变"],
|
||||||
|
source_raw_qa_id: 9003,
|
||||||
|
session_id: 9003
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function scoreQuestion(question: string, qa: CallableQAItem): number {
|
||||||
|
const haystack = [
|
||||||
|
qa.standard_question,
|
||||||
|
qa.standard_answer,
|
||||||
|
qa.primary_topic,
|
||||||
|
qa.course_stage,
|
||||||
|
...qa.problem_tags,
|
||||||
|
...qa.similar_questions
|
||||||
|
].join(" ");
|
||||||
|
return Array.from(new Set(question.trim().split(""))).reduce((score, char) => {
|
||||||
|
if (!char.trim()) return score;
|
||||||
|
return haystack.includes(char) ? score + 1 : score;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findQaAnswer(question: string, qaItems: CallableQAItem[]) {
|
||||||
|
const candidates = qaItems.length > 0 ? qaItems : mockCallableQa;
|
||||||
|
const [best] = [...candidates].sort((left, right) => scoreQuestion(question, right) - scoreQuestion(question, left));
|
||||||
|
return best ?? mockCallableQa[0];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user