Enhance H5 QA session interactions
This commit is contained in:
@@ -1,9 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Bot, UserRound } from "lucide-react";
|
import { Bot, Check, Copy, ThumbsDown, ThumbsUp, UserRound } from "lucide-react";
|
||||||
import type { ChatMessage } from "@/lib/h5MockQa";
|
import type { ChatMessage } from "@/lib/h5MockQa";
|
||||||
|
|
||||||
export default function ChatBubble({ message }: { message: ChatMessage }) {
|
export default function ChatBubble({
|
||||||
|
message,
|
||||||
|
onCopy,
|
||||||
|
onFeedback
|
||||||
|
}: {
|
||||||
|
message: ChatMessage;
|
||||||
|
onCopy?: (message: ChatMessage) => void;
|
||||||
|
onFeedback?: (messageId: string, feedback: "liked" | "disliked") => void;
|
||||||
|
}) {
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +37,38 @@ export default function ChatBubble({ message }: { message: ChatMessage }) {
|
|||||||
<div className="mt-3 rounded-md bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">{message.boundary}</div>
|
<div className="mt-3 rounded-md bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">{message.boundary}</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className={`mt-1 text-xs text-slate-400 ${isUser ? "text-right" : "text-left"}`}>{message.createdAt}</div>
|
<div className={`mt-1 flex items-center gap-2 text-xs text-slate-400 ${isUser ? "justify-end" : "justify-start"}`}>
|
||||||
|
<span>{message.createdAt}</span>
|
||||||
|
{!isUser ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onCopy?.(message)}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-slate-100 hover:text-jade"
|
||||||
|
aria-label="复制回答"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onFeedback?.(message.id, "liked")}
|
||||||
|
className={`flex h-7 w-7 items-center justify-center rounded-md hover:bg-teal-50 hover:text-jade ${
|
||||||
|
message.feedback === "liked" ? "bg-teal-50 text-jade" : ""
|
||||||
|
}`}
|
||||||
|
aria-label="回答有帮助"
|
||||||
|
>
|
||||||
|
{message.feedback === "liked" ? <Check className="h-3.5 w-3.5" aria-hidden /> : <ThumbsUp className="h-3.5 w-3.5" aria-hidden />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onFeedback?.(message.id, "disliked")}
|
||||||
|
className={`flex h-7 w-7 items-center justify-center rounded-md hover:bg-rose-50 hover:text-rose-600 ${
|
||||||
|
message.feedback === "disliked" ? "bg-rose-50 text-rose-600" : ""
|
||||||
|
}`}
|
||||||
|
aria-label="回答没解决"
|
||||||
|
>
|
||||||
|
<ThumbsDown className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isUser ? (
|
{isUser ? (
|
||||||
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-slate-200 text-slate-600">
|
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-slate-200 text-slate-600">
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { BookOpenCheck, Clock3, Home, MessageCircle, SendHorizonal, Settings, ShieldCheck, UserRound } from "lucide-react";
|
import {
|
||||||
|
BookOpenCheck,
|
||||||
|
Clock3,
|
||||||
|
Home,
|
||||||
|
MessageCircle,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
SendHorizonal,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
|
UserRound
|
||||||
|
} from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { CallableQAItem } from "@/lib/types";
|
import type { CallableQAItem } from "@/lib/types";
|
||||||
import {
|
import {
|
||||||
|
createEmptySession,
|
||||||
findQaAnswer,
|
findQaAnswer,
|
||||||
initialMessages,
|
initialMessages,
|
||||||
mockCallableQa,
|
mockCallableQa,
|
||||||
suggestedQuestions,
|
suggestedQuestions,
|
||||||
|
summarizeSession,
|
||||||
|
type ChatSession,
|
||||||
type ChatMessage
|
type ChatMessage
|
||||||
} from "@/lib/h5MockQa";
|
} from "@/lib/h5MockQa";
|
||||||
import ChatBubble from "./ChatBubble";
|
import ChatBubble from "./ChatBubble";
|
||||||
import MockAuthPanel, { type MockUser } from "./MockAuthPanel";
|
import MockAuthPanel, { type MockUser } from "./MockAuthPanel";
|
||||||
|
|
||||||
type MobileTab = "qa" | "history" | "me";
|
type MobileTab = "qa" | "history" | "me";
|
||||||
|
const STORAGE_KEY = "hy-qa-h5-sessions";
|
||||||
|
|
||||||
function nowText() {
|
function nowText() {
|
||||||
return new Intl.DateTimeFormat("zh-CN", {
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
@@ -35,25 +51,14 @@ function buildAnswerMessage(question: string, qa: CallableQAItem): ChatMessage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState({ tab }: { tab: MobileTab }) {
|
function formatSessionTime(value: string) {
|
||||||
const copy = {
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
history: "登录后这里会展示你的历史提问记录。当前先用本机 mock 会话演示。",
|
month: "2-digit",
|
||||||
me: "账号、学习阶段、隐私设置后续会接真实接口;现在先预留微信授权入口。",
|
day: "2-digit",
|
||||||
qa: ""
|
hour: "2-digit",
|
||||||
};
|
minute: "2-digit",
|
||||||
|
hour12: false
|
||||||
if (tab === "qa") return null;
|
}).format(new Date(value));
|
||||||
|
|
||||||
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() {
|
export default function H5QAExperience() {
|
||||||
@@ -61,13 +66,38 @@ export default function H5QAExperience() {
|
|||||||
const [authOpen, setAuthOpen] = useState(false);
|
const [authOpen, setAuthOpen] = useState(false);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
||||||
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||||
|
const [currentSessionId, setCurrentSessionId] = useState("");
|
||||||
const [callableQa, setCallableQa] = useState<CallableQAItem[]>([]);
|
const [callableQa, setCallableQa] = useState<CallableQAItem[]>([]);
|
||||||
const [loadingQa, setLoadingQa] = useState(true);
|
const [loadingQa, setLoadingQa] = useState(true);
|
||||||
const [user, setUser] = useState<MockUser | null>(null);
|
const [user, setUser] = useState<MockUser | null>(null);
|
||||||
|
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const qaSource = useMemo(() => (callableQa.length > 0 ? callableQa : mockCallableQa), [callableQa]);
|
const qaSource = useMemo(() => (callableQa.length > 0 ? callableQa : mockCallableQa), [callableQa]);
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
api
|
api
|
||||||
.get<CallableQAItem[]>("/standard-qa/callable")
|
.get<CallableQAItem[]>("/standard-qa/callable")
|
||||||
@@ -89,21 +119,73 @@ export default function H5QAExperience() {
|
|||||||
setAuthOpen(false);
|
setAuthOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
setActiveTab("qa");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSession(session: ChatSession) {
|
||||||
|
setCurrentSessionId(session.id);
|
||||||
|
setMessages(session.messages);
|
||||||
|
setActiveTab("qa");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHistory() {
|
||||||
|
const nextSession = createEmptySession();
|
||||||
|
setCurrentSessionId(nextSession.id);
|
||||||
|
setMessages(nextSession.messages);
|
||||||
|
setSessions([nextSession]);
|
||||||
|
setActiveTab("qa");
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function ask(question: string) {
|
||||||
const trimmed = question.trim();
|
const trimmed = question.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
const matchedQa = findQaAnswer(trimmed, qaSource);
|
const matchedQa = findQaAnswer(trimmed, qaSource);
|
||||||
setMessages((current) => [
|
const timestamp = Date.now();
|
||||||
...current,
|
const nextMessages: ChatMessage[] = [
|
||||||
|
...messages,
|
||||||
{
|
{
|
||||||
id: `user-${Date.now()}`,
|
id: `user-${timestamp}`,
|
||||||
role: "user",
|
role: "user",
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
createdAt: nowText()
|
createdAt: nowText()
|
||||||
},
|
},
|
||||||
buildAnswerMessage(trimmed, matchedQa)
|
{ ...buildAnswerMessage(trimmed, matchedQa), id: `assistant-${timestamp}` }
|
||||||
]);
|
];
|
||||||
|
persistMessages(nextMessages);
|
||||||
setInput("");
|
setInput("");
|
||||||
setActiveTab("qa");
|
setActiveTab("qa");
|
||||||
}
|
}
|
||||||
@@ -153,6 +235,16 @@ export default function H5QAExperience() {
|
|||||||
{user ? user.name.slice(0, 2) : <UserRound className="h-5 w-5" aria-hidden />}
|
{user ? user.name.slice(0, 2) : <UserRound className="h-5 w-5" aria-hidden />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<section className="relative flex min-h-0 flex-1 flex-col">
|
<section className="relative flex min-h-0 flex-1 flex-col">
|
||||||
@@ -164,7 +256,7 @@ export default function H5QAExperience() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<ChatBubble key={message.id} message={message} />
|
<ChatBubble key={message.id} message={message} onCopy={copyMessage} onFeedback={setFeedback} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
@@ -201,8 +293,85 @@ export default function H5QAExperience() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : activeTab === "history" ? (
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold">问答记录</h2>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">当前保存在本机,后续接用户接口。</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearHistory}
|
||||||
|
className="flex h-9 items-center gap-1 rounded-md border border-line px-2 text-xs text-slate-500 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<button
|
||||||
|
key={session.id}
|
||||||
|
onClick={() => openSession(session)}
|
||||||
|
className={`w-full rounded-md border bg-white p-3 text-left shadow-sm ${
|
||||||
|
session.id === currentSessionId ? "border-teal-200 ring-2 ring-teal-50" : "border-line"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium text-ink">{session.title}</div>
|
||||||
|
<div className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">{session.preview}</div>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-xs text-slate-400">{formatSessionTime(session.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs text-slate-400">{session.questionCount} 个问题</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : activeTab === "me" ? (
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||||
|
<section className="rounded-md border border-line bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-md bg-jade text-white">
|
||||||
|
<UserRound className="h-6 w-6" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-ink">{user?.name ?? "未登录用户"}</div>
|
||||||
|
<div className="mt-1 text-sm text-slate-500">{user?.phone ?? "登录后同步问答记录和反馈"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setAuthOpen(true)}
|
||||||
|
className="mt-4 h-10 w-full rounded-md bg-jade text-sm font-medium text-white hover:bg-teal-800"
|
||||||
|
>
|
||||||
|
{user ? "管理 mock 账号" : "登录 / 注册"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<section className="mt-4 space-y-3 rounded-md border border-line bg-white p-4 text-sm text-slate-600 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>本机问答记录</span>
|
||||||
|
<span className="font-medium text-ink">{sessions.length} 条</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>答案反馈</span>
|
||||||
|
<span className="font-medium text-ink">{messages.filter((message) => message.feedback).length} 条</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>数据模式</span>
|
||||||
|
<span className="font-medium text-ink">{callableQa.length > 0 ? "接口数据" : "mock 演示"}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<button
|
||||||
|
onClick={startNewSession}
|
||||||
|
className="mt-4 flex h-10 w-full items-center justify-center gap-2 rounded-md border border-line bg-white text-sm font-medium text-ink hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" aria-hidden />
|
||||||
|
开始新的问答
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState tab={activeTab} />
|
null
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MockAuthPanel
|
<MockAuthPanel
|
||||||
|
|||||||
@@ -8,9 +8,19 @@ export type ChatMessage = {
|
|||||||
content: string;
|
content: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
boundary?: string;
|
boundary?: string;
|
||||||
|
feedback?: "liked" | "disliked";
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChatSession = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
preview: string;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
updatedAt: string;
|
||||||
|
questionCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type SuggestedQuestion = {
|
export type SuggestedQuestion = {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -46,6 +56,32 @@ export const initialMessages: ChatMessage[] = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export function createEmptySession(): ChatSession {
|
||||||
|
return {
|
||||||
|
id: `session-${Date.now()}`,
|
||||||
|
title: "新的问答",
|
||||||
|
preview: "还没有开始提问",
|
||||||
|
messages: initialMessages,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
questionCount: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeSession(sessionId: string, messages: ChatMessage[]): ChatSession {
|
||||||
|
const userMessages = messages.filter((message) => message.role === "user");
|
||||||
|
const lastMessage = [...messages].reverse().find((message) => message.role === "assistant" || message.role === "user");
|
||||||
|
const firstQuestion = userMessages[0]?.content;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: sessionId,
|
||||||
|
title: firstQuestion ? firstQuestion.slice(0, 24) : "新的问答",
|
||||||
|
preview: lastMessage?.content.slice(0, 42) ?? "还没有开始提问",
|
||||||
|
messages,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
questionCount: userMessages.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const mockCallableQa: CallableQAItem[] = [
|
export const mockCallableQa: CallableQAItem[] = [
|
||||||
{
|
{
|
||||||
id: 9001,
|
id: 9001,
|
||||||
|
|||||||
Reference in New Issue
Block a user