feat(问翻小宝): 增强帮助搜索与会话历史
关键改动: - 增加问翻小宝会话历史记录规则与测试 - 优化帮助搜索、兜底建议和页面交互结构 - 更新帮助截图资产和截图采集脚本 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -1,18 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, KeyboardEvent, useMemo, useState } from 'react';
|
||||
import { FormEvent, KeyboardEvent, MouseEvent, useMemo, useState } from 'react';
|
||||
import {
|
||||
BotMessageSquare,
|
||||
Image as ImageIcon,
|
||||
Maximize2,
|
||||
MessageCircleQuestionMark,
|
||||
Mic,
|
||||
Search,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { WENFAN_HELP_ARTICLES, type HelpArticle } from '@/lib/wenfan-help-articles';
|
||||
import { getFallbackHelpSuggestions, searchHelpArticles, type HelpSearchResult } from '@/lib/wenfan-help-search';
|
||||
import {
|
||||
createWenfanConversationRecord,
|
||||
deleteWenfanConversationRecord,
|
||||
updateWenfanConversationRecord,
|
||||
type WenfanConversationRecord,
|
||||
} from '@/lib/wenfan-conversation-history';
|
||||
import {
|
||||
getFallbackHelpMessage,
|
||||
getFallbackHelpSuggestions,
|
||||
searchHelpArticles,
|
||||
shouldShowGenericHelpSuggestions,
|
||||
type HelpSearchResult,
|
||||
} from '@/lib/wenfan-help-search';
|
||||
|
||||
type ChatMessage =
|
||||
| {
|
||||
@@ -53,24 +68,64 @@ const INITIAL_MESSAGES: ChatMessage[] = [
|
||||
|
||||
const STARTER_QUESTIONS = getFallbackHelpSuggestions();
|
||||
|
||||
type WenfanConversation = WenfanConversationRecord<ChatMessage>;
|
||||
|
||||
const INITIAL_CONVERSATIONS: WenfanConversation[] = [
|
||||
{ id: 'history-product-create', title: '怎么新建产品?', messages: INITIAL_MESSAGES },
|
||||
{ id: 'history-version-requirements', title: '怎么把需求纳入版本?', messages: INITIAL_MESSAGES },
|
||||
{ id: 'history-dev-task-submit', title: '开发任务怎么提测?', messages: INITIAL_MESSAGES },
|
||||
{ id: 'history-test-bug', title: '测试用例失败后怎么提 Bug?', messages: INITIAL_MESSAGES },
|
||||
{ id: 'history-activity-log', title: '日志记录会记录哪些行为?', messages: INITIAL_MESSAGES },
|
||||
];
|
||||
|
||||
export default function WenfanXiaobaoPage() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(INITIAL_MESSAGES);
|
||||
const [input, setInput] = useState('');
|
||||
const [history, setHistory] = useState<string[]>([
|
||||
'怎么新建产品?',
|
||||
'怎么把需求纳入版本?',
|
||||
'开发任务怎么提测?',
|
||||
'测试用例失败后怎么提 Bug?',
|
||||
'日志记录会记录哪些行为?',
|
||||
]);
|
||||
const [conversations, setConversations] = useState<WenfanConversation[]>(INITIAL_CONVERSATIONS);
|
||||
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
||||
|
||||
const visibleHistory = useMemo(() => history.slice(0, 12), [history]);
|
||||
const visibleHistory = useMemo(() => conversations.slice(0, 12), [conversations]);
|
||||
const userQuestionCount = useMemo(
|
||||
() => messages.filter((message) => message.role === 'user').length,
|
||||
[messages],
|
||||
);
|
||||
const showGenericSuggestions = shouldShowGenericHelpSuggestions(userQuestionCount);
|
||||
const showStarterQuestions = showGenericSuggestions && userQuestionCount === 0;
|
||||
|
||||
function handleNewConversation() {
|
||||
const id = `conversation-${Date.now()}`;
|
||||
const record = createWenfanConversationRecord<ChatMessage>(id, INITIAL_MESSAGES);
|
||||
|
||||
setConversations((current) => [record, ...current]);
|
||||
setActiveConversationId(id);
|
||||
setMessages(INITIAL_MESSAGES);
|
||||
setInput('');
|
||||
}
|
||||
|
||||
function openConversation(conversation: WenfanConversation) {
|
||||
setActiveConversationId(conversation.id);
|
||||
setMessages(conversation.messages);
|
||||
setInput('');
|
||||
}
|
||||
|
||||
function deleteConversation(event: MouseEvent<HTMLButtonElement>, id: string) {
|
||||
event.stopPropagation();
|
||||
setConversations((current) => deleteWenfanConversationRecord(current, id));
|
||||
|
||||
if (activeConversationId === id) {
|
||||
setActiveConversationId(null);
|
||||
setMessages(INITIAL_MESSAGES);
|
||||
setInput('');
|
||||
}
|
||||
}
|
||||
|
||||
function askQuestion(rawQuestion: string) {
|
||||
const question = rawQuestion.trim();
|
||||
if (!question) return;
|
||||
|
||||
const results = searchHelpArticles(question, WENFAN_HELP_ARTICLES);
|
||||
const nextUserQuestionCount = userQuestionCount + 1;
|
||||
const showFallbackSuggestions = shouldShowGenericHelpSuggestions(nextUserQuestionCount);
|
||||
const userMessage: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
@@ -90,12 +145,14 @@ export default function WenfanXiaobaoPage() {
|
||||
id: `fallback-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
type: 'fallback',
|
||||
content: '暂时没有找到对应的内置帮助内容。你可以换个关键词,或者从下面这些常见问题开始。',
|
||||
suggestions: STARTER_QUESTIONS,
|
||||
content: getFallbackHelpMessage(showFallbackSuggestions),
|
||||
suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [],
|
||||
};
|
||||
|
||||
setMessages((current) => [...current, userMessage, assistantMessage]);
|
||||
setHistory((current) => [question, ...current.filter((item) => item !== question)]);
|
||||
const nextMessages = [...messages, userMessage, assistantMessage];
|
||||
|
||||
setMessages(nextMessages);
|
||||
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
|
||||
setInput('');
|
||||
}
|
||||
|
||||
@@ -118,10 +175,7 @@ export default function WenfanXiaobaoPage() {
|
||||
<div className="space-y-3 p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMessages(INITIAL_MESSAGES);
|
||||
setInput('');
|
||||
}}
|
||||
onClick={handleNewConversation}
|
||||
className="flex h-10 w-full items-center gap-2 rounded-lg px-3 text-left text-[14px] font-medium text-[#171717] hover:bg-white"
|
||||
>
|
||||
<SquarePen className="h-4 w-4" strokeWidth={1.9} />
|
||||
@@ -139,20 +193,33 @@ export default function WenfanXiaobaoPage() {
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||
<p className="px-3 py-2 text-[12px] font-medium text-[#6b6b6b]">历史记录</p>
|
||||
<div className="space-y-1">
|
||||
{visibleHistory.map((item, index) => (
|
||||
<button
|
||||
key={`${item}-${index}`}
|
||||
type="button"
|
||||
onClick={() => askQuestion(item)}
|
||||
className={`w-full rounded-lg p-3 text-left transition-colors ${
|
||||
index === 0
|
||||
{visibleHistory.map((conversation, index) => (
|
||||
<div
|
||||
key={conversation.id}
|
||||
className={`group flex items-start gap-1 rounded-lg p-1.5 transition-colors ${
|
||||
conversation.id === activeConversationId
|
||||
? 'border border-[var(--accent)] bg-[var(--accent-soft)]'
|
||||
: 'bg-white hover:bg-[var(--bg-subtle)]'
|
||||
}`}
|
||||
>
|
||||
<p className="truncate text-[13px] leading-5 text-[#202123]">{item}</p>
|
||||
<p className="mt-0.5 text-[11px] text-[#8a8a8a]">{index === 0 ? '最近' : '历史'}</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openConversation(conversation)}
|
||||
className="min-w-0 flex-1 rounded-md px-1.5 py-1.5 text-left"
|
||||
>
|
||||
<p className="truncate text-[13px] leading-5 text-[#202123]">{conversation.title}</p>
|
||||
<p className="mt-0.5 text-[11px] text-[#8a8a8a]">{index === 0 ? '最近' : '历史'}</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => deleteConversation(event, conversation.id)}
|
||||
className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[#8a8a8a] opacity-0 transition-opacity hover:bg-white hover:text-[#171717] focus:opacity-100 group-hover:opacity-100"
|
||||
aria-label={`删除历史记录:${conversation.title}`}
|
||||
title="删除历史记录"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.9} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,21 +241,28 @@ export default function WenfanXiaobaoPage() {
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-7 px-6 py-8">
|
||||
{messages.map((message) => (
|
||||
<MessageRow key={message.id} message={message} onAsk={askQuestion} />
|
||||
<MessageRow
|
||||
key={message.id}
|
||||
message={message}
|
||||
onAsk={askQuestion}
|
||||
showGenericSuggestions={showGenericSuggestions}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{STARTER_QUESTIONS.map((question) => (
|
||||
<button
|
||||
key={question}
|
||||
type="button"
|
||||
onClick={() => askQuestion(question)}
|
||||
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{showStarterQuestions && (
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{STARTER_QUESTIONS.map((question) => (
|
||||
<button
|
||||
key={question}
|
||||
type="button"
|
||||
onClick={() => askQuestion(question)}
|
||||
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] transition-colors hover:bg-[#f7f7f8]"
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -238,7 +312,15 @@ export default function WenfanXiaobaoPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRow({ message, onAsk }: { message: ChatMessage; onAsk: (question: string) => void }) {
|
||||
function MessageRow({
|
||||
message,
|
||||
onAsk,
|
||||
showGenericSuggestions,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
onAsk: (question: string) => void;
|
||||
showGenericSuggestions: boolean;
|
||||
}) {
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
@@ -260,19 +342,23 @@ function MessageRow({ message, onAsk }: { message: ChatMessage; onAsk: (question
|
||||
{message.type === 'article' && <HelpAnswer result={message.result} onAsk={onAsk} />}
|
||||
{message.type === 'fallback' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[14px] leading-7 text-[#202123]">{message.content}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{message.suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
onClick={() => onAsk(suggestion)}
|
||||
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] hover:bg-[#f7f7f8]"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[14px] leading-7 text-[#202123]">
|
||||
{getFallbackHelpMessage(showGenericSuggestions && message.suggestions.length > 0)}
|
||||
</p>
|
||||
{showGenericSuggestions && message.suggestions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{message.suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
onClick={() => onAsk(suggestion)}
|
||||
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] hover:bg-[#f7f7f8]"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -282,6 +368,7 @@ function MessageRow({ message, onAsk }: { message: ChatMessage; onAsk: (question
|
||||
|
||||
function HelpAnswer({ result, onAsk }: { result: HelpSearchResult; onAsk: (question: string) => void }) {
|
||||
const { article, matchedKeywords, relatedArticles } = result;
|
||||
const [previewImage, setPreviewImage] = useState<HelpArticle['images'][number] | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-5 text-[14px] leading-7 text-[#202123]">
|
||||
@@ -313,7 +400,18 @@ function HelpAnswer({ result, onAsk }: { result: HelpSearchResult; onAsk: (quest
|
||||
<div className="grid gap-3">
|
||||
{article.images.map((image) => (
|
||||
<figure key={image.src} className="overflow-hidden rounded-xl border border-[#ececec] bg-white">
|
||||
<img src={image.src} alt={image.alt} className="max-h-[320px] w-full object-cover object-top" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewImage(image)}
|
||||
className="group relative block w-full overflow-hidden text-left"
|
||||
aria-label={`放大查看:${image.caption}`}
|
||||
>
|
||||
<img src={image.src} alt={image.alt} className="max-h-[320px] w-full object-cover object-top" />
|
||||
<span className="absolute bottom-3 right-3 inline-flex items-center gap-1.5 rounded-full bg-[#171717]/85 px-2.5 py-1 text-[11px] font-medium text-white opacity-0 shadow-sm transition-opacity group-hover:opacity-100">
|
||||
<Maximize2 className="h-3.5 w-3.5" strokeWidth={1.9} />
|
||||
点击放大
|
||||
</span>
|
||||
</button>
|
||||
<figcaption className="border-t border-[#ececec] px-3 py-2 text-[12px] text-[#6b6b6b]">
|
||||
{image.caption}
|
||||
</figcaption>
|
||||
@@ -323,6 +421,43 @@ function HelpAnswer({ result, onAsk }: { result: HelpSearchResult; onAsk: (quest
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewImage && (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/70 px-8 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={previewImage.caption}
|
||||
onClick={() => setPreviewImage(null)}
|
||||
>
|
||||
<div
|
||||
className="flex max-h-full w-full max-w-6xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b border-[#ececec] px-4">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[13px] font-semibold text-[#171717]">{previewImage.caption}</p>
|
||||
<p className="truncate text-[11px] text-[#6b6b6b]">{previewImage.alt}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewImage(null)}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[#6b6b6b] hover:bg-[#f4f4f4] hover:text-[#171717]"
|
||||
aria-label="关闭图片预览"
|
||||
>
|
||||
<X className="h-4 w-4" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-[#111] p-4">
|
||||
<img
|
||||
src={previewImage.src}
|
||||
alt={previewImage.alt}
|
||||
className="mx-auto max-h-[calc(100vh-160px)] w-auto max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{relatedArticles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="flex items-center gap-2 text-[13px] font-semibold text-[#171717]">
|
||||
|
||||
Reference in New Issue
Block a user