545 lines
21 KiB
TypeScript
545 lines
21 KiB
TypeScript
'use client';
|
|
|
|
import { FormEvent, KeyboardEvent, MouseEvent, useMemo, useState } from 'react';
|
|
import type { AnalysisResponse } from '@ftb/shared';
|
|
import {
|
|
BotMessageSquare,
|
|
Image as ImageIcon,
|
|
Maximize2,
|
|
MessageCircleQuestionMark,
|
|
Mic,
|
|
Search,
|
|
SendHorizontal,
|
|
Sparkles,
|
|
SquarePen,
|
|
Trash2,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { AnalysisResultBlock } from '@/components/analysis/AnalysisResultBlock';
|
|
import { requestAnalysis } from '@/lib/analysis-api';
|
|
import { WENFAN_HELP_ARTICLES, type HelpArticle } from '@/lib/wenfan-help-articles';
|
|
import {
|
|
createWenfanConversationRecord,
|
|
deleteWenfanConversationRecord,
|
|
updateWenfanConversationRecord,
|
|
type WenfanConversationRecord,
|
|
} from '@/lib/wenfan-conversation-history';
|
|
import {
|
|
getFallbackHelpMessage,
|
|
getFallbackHelpSuggestions,
|
|
searchHelpArticles,
|
|
shouldShowGenericHelpSuggestions,
|
|
type HelpSearchResult,
|
|
} from '@/lib/wenfan-help-search';
|
|
import { useAuthStore } from '@/stores/useAuthStore';
|
|
import { useMemberStore } from '@/stores/useMemberStore';
|
|
|
|
type ChatMessage =
|
|
| {
|
|
id: string;
|
|
role: 'assistant';
|
|
type: 'intro';
|
|
content: string;
|
|
}
|
|
| {
|
|
id: string;
|
|
role: 'user';
|
|
type: 'text';
|
|
content: string;
|
|
}
|
|
| {
|
|
id: string;
|
|
role: 'assistant';
|
|
type: 'article';
|
|
result: HelpSearchResult;
|
|
}
|
|
| {
|
|
id: string;
|
|
role: 'assistant';
|
|
type: 'fallback';
|
|
content: string;
|
|
suggestions: string[];
|
|
}
|
|
| {
|
|
id: string;
|
|
role: 'assistant';
|
|
type: 'analysis';
|
|
response: AnalysisResponse;
|
|
}
|
|
| {
|
|
id: string;
|
|
role: 'assistant';
|
|
type: 'analysis_error';
|
|
content: string;
|
|
};
|
|
|
|
const INITIAL_MESSAGES: ChatMessage[] = [
|
|
{
|
|
id: 'intro',
|
|
role: 'assistant',
|
|
type: 'intro',
|
|
content:
|
|
'你可以问系统怎么用,也可以问业务数据,例如:哪个部门最忙、哪些版本风险最高、需求完成趋势怎么样。业务分析只读取你已有权限的数据。',
|
|
},
|
|
];
|
|
|
|
const STARTER_QUESTIONS = getFallbackHelpSuggestions();
|
|
|
|
type WenfanConversation = WenfanConversationRecord<ChatMessage>;
|
|
|
|
export default function WenfanXiaobaoPage() {
|
|
const [messages, setMessages] = useState<ChatMessage[]>(INITIAL_MESSAGES);
|
|
const [input, setInput] = useState('');
|
|
const [conversations, setConversations] = useState<WenfanConversation[]>([]);
|
|
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
|
|
const user = useAuthStore((state) => state.user);
|
|
const roles = useMemberStore((state) => state.roles);
|
|
|
|
const visibleHistory = useMemo(() => conversations.slice(0, 12), [conversations]);
|
|
const currentPermissions = useMemo(
|
|
() => roles.find((role) => role.id === user?.roleId)?.permissions ?? [],
|
|
[roles, user?.roleId],
|
|
);
|
|
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('');
|
|
}
|
|
}
|
|
|
|
async function askQuestion(rawQuestion: string) {
|
|
const question = rawQuestion.trim();
|
|
if (!question) return;
|
|
|
|
const userMessage: ChatMessage = {
|
|
id: `user-${Date.now()}`,
|
|
role: 'user',
|
|
type: 'text',
|
|
content: question,
|
|
};
|
|
const optimisticMessages = [...messages, userMessage];
|
|
|
|
setMessages(optimisticMessages);
|
|
setInput('');
|
|
|
|
try {
|
|
const analysis = await requestAnalysis(
|
|
{ question, context: { surface: 'ai_assistant' } },
|
|
currentPermissions,
|
|
);
|
|
const assistantMessage: ChatMessage = {
|
|
id: `analysis-${Date.now()}`,
|
|
role: 'assistant',
|
|
type: 'analysis',
|
|
response: analysis,
|
|
};
|
|
const nextMessages = [...optimisticMessages, assistantMessage];
|
|
|
|
setMessages(nextMessages);
|
|
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
|
|
return;
|
|
} catch {
|
|
const results = searchHelpArticles(question, WENFAN_HELP_ARTICLES);
|
|
const nextUserQuestionCount = userQuestionCount + 1;
|
|
const showFallbackSuggestions = shouldShowGenericHelpSuggestions(nextUserQuestionCount);
|
|
const analysisErrorMessage: ChatMessage = {
|
|
id: `analysis-error-${Date.now()}`,
|
|
role: 'assistant',
|
|
type: 'analysis_error',
|
|
content: '业务分析暂不可用,我先用内置帮助继续回答。',
|
|
};
|
|
const assistantMessage: ChatMessage =
|
|
results.length > 0
|
|
? {
|
|
id: `article-${Date.now()}`,
|
|
role: 'assistant',
|
|
type: 'article',
|
|
result: results[0],
|
|
}
|
|
: {
|
|
id: `fallback-${Date.now()}`,
|
|
role: 'assistant',
|
|
type: 'fallback',
|
|
content: getFallbackHelpMessage(showFallbackSuggestions),
|
|
suggestions: showFallbackSuggestions ? STARTER_QUESTIONS : [],
|
|
};
|
|
const nextMessages = [...optimisticMessages, analysisErrorMessage, assistantMessage];
|
|
|
|
setMessages(nextMessages);
|
|
setConversations((current) => updateWenfanConversationRecord(current, activeConversationId, nextMessages));
|
|
}
|
|
}
|
|
|
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
void askQuestion(input);
|
|
}
|
|
|
|
function handleTextareaKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault();
|
|
void askQuestion(input);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="h-full bg-white text-[#171717]">
|
|
<main className="grid h-full min-h-0 grid-cols-1 overflow-hidden lg:grid-cols-[300px_minmax(0,1fr)]">
|
|
<aside className="hidden min-h-0 flex-col border-r border-[#e5e5e5] bg-white lg:flex">
|
|
<div className="space-y-3 p-3">
|
|
<button
|
|
type="button"
|
|
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} />
|
|
新对话
|
|
</button>
|
|
<label className="flex h-9 items-center gap-2 rounded-lg bg-white px-3 text-[#8a8a8a] ring-1 ring-[#e5e5e5]">
|
|
<Search className="h-4 w-4 shrink-0" strokeWidth={1.8} />
|
|
<input
|
|
className="min-w-0 flex-1 bg-transparent text-[13px] text-[#171717] outline-none placeholder:text-[#8a8a8a]"
|
|
placeholder="搜索历史记录"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<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((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)]'
|
|
}`}
|
|
>
|
|
<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>
|
|
</aside>
|
|
|
|
<section className="flex min-h-0 flex-col bg-white">
|
|
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[#ececec] px-5">
|
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#f2f2f2] text-[#171717]">
|
|
<MessageCircleQuestionMark className="h-4 w-4" strokeWidth={2} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h1 className="truncate text-[15px] font-semibold text-[#171717]">AI 助手</h1>
|
|
</div>
|
|
</div>
|
|
<span className="rounded-full bg-[#f4f4f4] px-3 py-1 text-[12px] text-[#6b6b6b]">只读分析 · 内置帮助</span>
|
|
</header>
|
|
|
|
<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}
|
|
showGenericSuggestions={showGenericSuggestions}
|
|
/>
|
|
))}
|
|
|
|
{showStarterQuestions && (
|
|
<div className="flex flex-wrap gap-2 pt-2">
|
|
{STARTER_QUESTIONS.map((question) => (
|
|
<button
|
|
key={question}
|
|
type="button"
|
|
onClick={() => void 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>
|
|
|
|
<footer className="shrink-0 bg-white px-6 pb-6 pt-2">
|
|
<form className="mx-auto w-full max-w-3xl" onSubmit={handleSubmit}>
|
|
<div className="rounded-[28px] border border-[#d9d9e3] bg-white shadow-[0_8px_28px_rgba(0,0,0,0.08)] focus-within:border-[#b9b9c6]">
|
|
<textarea
|
|
rows={2}
|
|
value={input}
|
|
onChange={(event) => setInput(event.target.value)}
|
|
onKeyDown={handleTextareaKeyDown}
|
|
className="max-h-32 min-h-14 w-full resize-none bg-transparent px-5 pt-4 text-[14px] leading-6 text-[#171717] outline-none placeholder:text-[#8a8a8a]"
|
|
placeholder="输入系统使用问题,例如:需求怎么纳入版本?"
|
|
/>
|
|
<div className="flex items-center justify-between px-3 pb-3">
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
className="flex h-8 w-8 items-center justify-center rounded-full text-[#6b6b6b] hover:bg-[#f4f4f4] hover:text-[#171717]"
|
|
title="语音输入"
|
|
>
|
|
<Mic className="h-4 w-4" strokeWidth={1.9} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="flex h-8 w-8 items-center justify-center rounded-full text-[#6b6b6b] hover:bg-[#f4f4f4] hover:text-[#171717]"
|
|
title="内置帮助"
|
|
>
|
|
<BotMessageSquare className="h-4 w-4" strokeWidth={1.9} />
|
|
</button>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#171717] text-white transition-colors hover:bg-[#303030] disabled:bg-[#c8c8c8]"
|
|
title="发送"
|
|
disabled={!input.trim()}
|
|
>
|
|
<SendHorizontal className="h-4 w-4" strokeWidth={2} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</footer>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MessageRow({
|
|
message,
|
|
onAsk,
|
|
showGenericSuggestions,
|
|
}: {
|
|
message: ChatMessage;
|
|
onAsk: (question: string) => void;
|
|
showGenericSuggestions: boolean;
|
|
}) {
|
|
if (message.role === 'user') {
|
|
return (
|
|
<div className="flex justify-end">
|
|
<div className="max-w-[76%] rounded-3xl bg-[#f4f4f4] px-4 py-2.5 text-[14px] leading-6 text-[#171717]">
|
|
{message.content}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex gap-3">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#171717] text-white">
|
|
<BotMessageSquare className="h-4 w-4" strokeWidth={1.9} />
|
|
</div>
|
|
<div className="min-w-0 flex-1 pt-0.5">
|
|
{message.type === 'intro' && <p className="text-[14px] leading-7 text-[#202123]">{message.content}</p>}
|
|
{message.type === 'analysis' && <AnalysisResultBlock response={message.response} onAsk={onAsk} />}
|
|
{message.type === 'analysis_error' && (
|
|
<p className="rounded-[22px] border border-[#e2e8f0] bg-white/75 px-4 py-3 text-[13px] leading-6 text-[#64748b] shadow-sm backdrop-blur">
|
|
{message.content}
|
|
</p>
|
|
)}
|
|
{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]">
|
|
{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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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]">
|
|
<div>
|
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
|
<span className="rounded-full bg-[#f4f4f4] px-2.5 py-1 text-[12px] text-[#6b6b6b]">{article.category}</span>
|
|
{matchedKeywords.slice(0, 3).map((keyword) => (
|
|
<span key={keyword} className="rounded-full bg-[var(--accent-soft)] px-2.5 py-1 text-[12px] text-[var(--accent)]">
|
|
{keyword}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<h2 className="text-[18px] font-semibold leading-7 text-[#171717]">{article.title}</h2>
|
|
<p className="mt-2 text-[#3f3f46]">{article.scenario}</p>
|
|
</div>
|
|
|
|
<HelpSection title="入口路径" items={[article.entry]} />
|
|
<HelpSection title="操作步骤" items={article.steps} ordered />
|
|
<HelpSection title="必填字段" items={article.requiredFields} />
|
|
<HelpSection title="状态流转" items={article.statusFlow} />
|
|
<HelpSection title="注意事项" items={article.notes} />
|
|
|
|
{article.images.length > 0 && (
|
|
<div className="space-y-2">
|
|
<p className="flex items-center gap-2 text-[13px] font-semibold text-[#171717]">
|
|
<ImageIcon className="h-4 w-4" strokeWidth={1.9} />
|
|
配图
|
|
</p>
|
|
<div className="grid gap-3">
|
|
{article.images.map((image) => (
|
|
<figure key={image.src} className="overflow-hidden rounded-xl border border-[#ececec] bg-white">
|
|
<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>
|
|
</figure>
|
|
))}
|
|
</div>
|
|
</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]">
|
|
<Sparkles className="h-4 w-4" strokeWidth={1.9} />
|
|
相关帮助
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{relatedArticles.map((related) => (
|
|
<button
|
|
key={related.id}
|
|
type="button"
|
|
onClick={() => onAsk(related.title)}
|
|
className="rounded-full border border-[#dedede] px-3 py-2 text-[13px] text-[#3f3f46] hover:bg-[#f7f7f8]"
|
|
>
|
|
{related.title}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HelpSection({ title, items, ordered = false }: { title: string; items: string[]; ordered?: boolean }) {
|
|
const ListTag = ordered ? 'ol' : 'ul';
|
|
|
|
return (
|
|
<section className="space-y-2">
|
|
<h3 className="text-[13px] font-semibold text-[#171717]">{title}</h3>
|
|
<ListTag className={`space-y-1 ${ordered ? 'list-decimal' : 'list-disc'} pl-5 marker:text-[#9ca3af]`}>
|
|
{items.map((item) => (
|
|
<li key={item}>{item}</li>
|
|
))}
|
|
</ListTag>
|
|
</section>
|
|
);
|
|
}
|