feat(nac-admin): AI问答页面改版为ChatGPT风格对话界面 - NAC公链AI

This commit is contained in:
nacadmin 2026-03-01 16:08:08 +08:00
parent 8d6ada2692
commit c8e5e7189f
1 changed files with 587 additions and 0 deletions

View File

@ -0,0 +1,587 @@
import { useState, useEffect, useRef } from "react";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
BookOpen, Shield, Languages, ClipboardCheck,
Send, Bot, User, Loader2, Sparkles,
AlertCircle, CheckCircle2, Info, MessageSquare,
Plus, Trash2, Clock, ChevronDown, ChevronUp,
ExternalLink, Zap, SquarePen,
} from "lucide-react";
import { useLocation } from "wouter";
// ─── 类型定义 ─────────────────────────────────────────────────────
type AgentType = "knowledge_qa" | "compliance" | "translation" | "approval_assist";
interface Message {
role: "user" | "assistant";
content: string;
confidence?: number;
sources?: string[];
suggestions?: string[];
isRTL?: boolean;
}
interface ConversationItem {
conversationId: string;
agentType: AgentType;
title: string;
messageCount: number;
updatedAt: string | Date;
}
// ─── 图标映射 ─────────────────────────────────────────────────────
const ICON_MAP: Record<string, React.ElementType> = {
BookOpen, Shield, Languages, ClipboardCheck,
};
const AGENT_LABELS: Record<AgentType, { name: string; desc: string; color: string; icon: string }> = {
knowledge_qa: { name: "知识问答", desc: "NAC公链技术与规则", color: "text-blue-400", icon: "BookOpen" },
compliance: { name: "合规审查", desc: "RWA合规与监管分析", color: "text-red-400", icon: "Shield" },
translation: { name: "多语翻译", desc: "10种语言专业翻译", color: "text-green-400", icon: "Languages" },
approval_assist: { name: "审批助手", desc: "资产审批流程辅助", color: "text-purple-400", icon: "ClipboardCheck" },
};
// ─── 置信度徽章 ───────────────────────────────────────────────────
function ConfidenceBadge({ confidence }: { confidence: number }) {
const pct = Math.round(confidence * 100);
const color = pct >= 80 ? "text-green-400" : pct >= 60 ? "text-yellow-400" : "text-red-400";
return (
<span className={`text-xs ${color} flex items-center gap-1`}>
<CheckCircle2 className="w-3 h-3" />
{pct}%
</span>
);
}
// ─── 来源引用徽章 ─────────────────────────────────────────────────
function SourceBadge({ source, onClick }: { source: string; onClick: () => void }) {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300 transition-colors bg-blue-950/40 rounded px-1.5 py-0.5 border border-blue-800/50"
title={`点击跳转到知识库:${source}`}
>
<BookOpen className="w-2.5 h-2.5 shrink-0" />
<span className="max-w-[120px] truncate">{source}</span>
<ExternalLink className="w-2.5 h-2.5 shrink-0" />
</button>
);
}
// ─── 消息气泡 ─────────────────────────────────────────────────────
function MessageBubble({
msg,
onSourceClick,
}: {
msg: Message;
onSourceClick?: (source: string) => void;
}) {
const isUser = msg.role === "user";
return (
<div className={`group flex gap-4 px-4 py-5 ${isUser ? "flex-row-reverse" : "flex-row"}`}>
{/* 头像 */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-0.5 ${
isUser
? "bg-indigo-600 text-white"
: "bg-gradient-to-br from-cyan-500 to-blue-600 text-white"
}`}>
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
</div>
{/* 内容区 */}
<div className={`flex flex-col gap-2 max-w-[80%] ${isUser ? "items-end" : "items-start"}`}>
{/* 发送者名称 */}
<span className="text-xs text-zinc-500 font-medium px-1">
{isUser ? "你" : "NAC公链AI"}
</span>
{/* 消息内容 */}
<div
className={`rounded-2xl px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap break-words ${
isUser
? "bg-indigo-600 text-white rounded-tr-sm"
: "bg-zinc-800 text-zinc-100 rounded-tl-sm border border-zinc-700/50"
}`}
dir={msg.isRTL ? "rtl" : "ltr"}
>
{msg.content}
</div>
{/* AI 附加信息 */}
{!isUser && (
<div className="flex flex-wrap gap-2 px-1">
{msg.confidence !== undefined && <ConfidenceBadge confidence={msg.confidence} />}
{msg.sources && msg.sources.length > 0 && (
<div className="flex flex-wrap items-center gap-1">
<span className="text-xs text-zinc-500 flex items-center gap-1">
<Info className="w-3 h-3" />
</span>
{msg.sources.slice(0, 3).map((src, i) => (
<SourceBadge key={i} source={src} onClick={() => onSourceClick?.(src)} />
))}
{msg.sources.length > 3 && (
<span className="text-xs text-zinc-500">+{msg.sources.length - 3}</span>
)}
</div>
)}
</div>
)}
{/* 建议标签 */}
{!isUser && msg.suggestions && msg.suggestions.length > 0 && (
<div className="flex flex-wrap gap-1 px-1">
{msg.suggestions.map((s, i) => (
<Badge key={i} variant="outline" className="text-xs border-zinc-600 text-zinc-400">
{s}
</Badge>
))}
</div>
)}
</div>
</div>
);
}
// ─── 会话列表项 ───────────────────────────────────────────────────
function ConversationListItem({
conv,
isActive,
onClick,
onDelete,
}: {
conv: ConversationItem;
isActive: boolean;
onClick: () => void;
onDelete: () => void;
}) {
const updatedAt = new Date(conv.updatedAt);
const timeStr = updatedAt.toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
return (
<div
className={`group flex items-center gap-2 rounded-lg px-3 py-2.5 cursor-pointer transition-all ${
isActive
? "bg-zinc-700/60 text-white"
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
}`}
onClick={onClick}
>
<MessageSquare className="w-3.5 h-3.5 shrink-0 opacity-60" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate leading-snug">{conv.title}</p>
<p className="text-[10px] text-zinc-600 mt-0.5">{timeStr} · {conv.messageCount}</p>
</div>
<button
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:text-red-400 transition-all shrink-0"
onClick={e => { e.stopPropagation(); onDelete(); }}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
);
}
// ─── 欢迎屏幕(无消息时) ─────────────────────────────────────────
function WelcomeScreen({
agentType,
suggestedQuestions,
onSelect,
}: {
agentType: AgentType | null;
suggestedQuestions: string[];
onSelect: (q: string) => void;
}) {
const info = agentType ? AGENT_LABELS[agentType] : null;
const IconComp = info ? (ICON_MAP[info.icon] || Bot) : Bot;
return (
<div className="flex flex-col items-center justify-center h-full px-6 py-12 text-center">
{/* Logo 区域 */}
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center mb-5 shadow-lg shadow-blue-900/30">
<IconComp className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-white mb-2">NAC公链AI</h1>
{info && (
<p className="text-zinc-400 text-sm mb-1">{info.name} · {info.desc}</p>
)}
<p className="text-zinc-600 text-xs mb-8"> NAC · CBPP共识 · Charter合约</p>
{/* 建议问题 */}
{suggestedQuestions.length > 0 && (
<div className="w-full max-w-lg">
<p className="text-xs text-zinc-500 mb-3"></p>
<div className="grid grid-cols-1 gap-2">
{suggestedQuestions.map((q, i) => (
<button
key={i}
onClick={() => onSelect(q)}
className="text-left text-sm text-zinc-300 bg-zinc-800/60 hover:bg-zinc-700/60 border border-zinc-700/50 hover:border-zinc-600 rounded-xl px-4 py-3 transition-all"
>
{q}
</button>
))}
</div>
</div>
)}
</div>
);
}
// ─── 主页面 ───────────────────────────────────────────────────────
export default function AIAgents() {
const [selectedAgent, setSelectedAgent] = useState<AgentType>("knowledge_qa");
const [activeConvId, setActiveConvId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [inputText, setInputText] = useState("");
const [showHistory, setShowHistory] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [, navigate] = useLocation();
// 来源引用跳转
const handleSourceClick = (source: string) => {
const keyword = source.split("·").pop() || source;
navigate(`/knowledge-base?search=${encodeURIComponent(keyword)}`);
};
// 获取 Agent 列表和状态
const { data: agentData, isLoading: agentLoading } = trpc.aiAgent.list.useQuery();
const { data: statusData } = trpc.aiAgent.status.useQuery();
// 获取会话列表
const { data: convData, refetch: refetchConvs } = trpc.aiAgent.listConversations.useQuery(
{ agentType: selectedAgent || undefined, limit: 50 },
{ enabled: true }
);
// 加载历史消息
const loadHistoryQuery = trpc.aiAgent.getConversation.useQuery(
{ conversationId: activeConvId! },
{ enabled: !!activeConvId, refetchOnWindowFocus: false }
);
useEffect(() => {
if (loadHistoryQuery.data?.messages && activeConvId) {
setMessages(
(loadHistoryQuery.data.messages as Array<{ role: string; content: string; metadata?: Record<string, unknown> }>).map(m => ({
role: m.role as "user" | "assistant",
content: m.content,
confidence: m.metadata?.confidence as number | undefined,
sources: m.metadata?.sources as string[] | undefined,
suggestions: m.metadata?.suggestions as string[] | undefined,
isRTL: m.metadata?.isRTL as boolean | undefined,
}))
);
}
}, [loadHistoryQuery.data, activeConvId]);
// 删除会话
const deleteConvMutation = trpc.aiAgent.deleteConversation.useMutation({
onSuccess: () => { refetchConvs(); },
onError: (e) => toast.error(`删除失败: ${e.message}`),
});
// 发送消息
const chatMutation = trpc.aiAgent.chat.useMutation({
onSuccess: (response) => {
setMessages(prev => [...prev, {
role: "assistant",
content: response.answer,
confidence: response.confidence,
sources: response.sources,
suggestions: response.suggestions,
isRTL: response.metadata?.isRTL as boolean,
}]);
if (!activeConvId && response.conversationId) {
setActiveConvId(response.conversationId);
}
refetchConvs();
},
onError: (error) => {
toast.error(`响应失败: ${error.message}`);
},
});
// 自动滚动到底部
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// 切换 Agent
const handleSelectAgent = (type: AgentType) => {
setSelectedAgent(type);
setActiveConvId(null);
setMessages([]);
setInputText("");
};
// 点击历史会话
const handleSelectConversation = (conv: ConversationItem) => {
setSelectedAgent(conv.agentType);
setActiveConvId(conv.conversationId);
setMessages([]);
};
// 新建会话
const handleNewConversation = () => {
setActiveConvId(null);
setMessages([]);
setInputText("");
setTimeout(() => textareaRef.current?.focus(), 100);
};
// 发送
const handleSend = () => {
const text = inputText.trim();
if (!text || chatMutation.isPending || !selectedAgent) return;
setMessages(prev => [...prev, { role: "user", content: text }]);
setInputText("");
chatMutation.mutate({
agentType: selectedAgent,
userMessage: text,
conversationId: activeConvId || undefined,
persistHistory: true,
});
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const currentAgent = agentData?.agents.find(a => a.type === selectedAgent);
const conversations = (convData?.conversations || []) as ConversationItem[];
const agentInfo = AGENT_LABELS[selectedAgent];
return (
<div className="flex h-full overflow-hidden bg-zinc-900 text-white">
{/*
*/}
<div className="w-64 shrink-0 flex flex-col bg-zinc-950 border-r border-zinc-800">
{/* 顶部 Logo + 新建 */}
<div className="flex items-center justify-between px-4 py-4 border-b border-zinc-800">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center">
<Zap className="w-3.5 h-3.5 text-white" />
</div>
<span className="text-sm font-semibold text-white">NAC公链AI</span>
</div>
<button
onClick={handleNewConversation}
className="p-1.5 rounded-lg hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
title="新建对话"
>
<SquarePen className="w-4 h-4" />
</button>
</div>
{/* AI 状态 */}
{statusData && (
<div className={`mx-3 mt-3 flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg ${
statusData.configured
? "bg-green-950/60 text-green-400 border border-green-900/50"
: "bg-yellow-950/60 text-yellow-400 border border-yellow-900/50"
}`}>
{statusData.configured
? <><CheckCircle2 className="w-3 h-3 shrink-0" /><span>AI </span></>
: <><AlertCircle className="w-3 h-3 shrink-0" /><span>AI </span></>
}
</div>
)}
{/* Agent 切换 */}
<div className="px-3 mt-4">
<p className="text-[10px] font-semibold text-zinc-600 uppercase tracking-wider mb-2 px-1"></p>
<div className="space-y-0.5">
{agentLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="w-4 h-4 animate-spin text-zinc-600" />
</div>
) : (
agentData?.agents.map((agent) => {
const info = AGENT_LABELS[agent.type as AgentType];
const IconComp = ICON_MAP[agent.icon] || Bot;
const isActive = selectedAgent === agent.type;
return (
<button
key={agent.type}
onClick={() => handleSelectAgent(agent.type as AgentType)}
className={`w-full text-left rounded-lg px-3 py-2 transition-all flex items-center gap-2.5 ${
isActive
? "bg-zinc-700/70 text-white"
: "text-zinc-400 hover:bg-zinc-800/70 hover:text-zinc-200"
}`}
>
<IconComp className={`w-3.5 h-3.5 shrink-0 ${info?.color || "text-zinc-400"}`} />
<span className="text-xs font-medium">{agent.name}</span>
</button>
);
})
)}
</div>
</div>
{/* 历史会话 */}
<div className="flex-1 overflow-y-auto mt-4 px-3">
<button
className="w-full flex items-center justify-between px-1 py-1.5 text-[10px] font-semibold text-zinc-600 uppercase tracking-wider hover:text-zinc-400 transition-colors"
onClick={() => setShowHistory(v => !v)}
>
<span> {conversations.length > 0 && `(${conversations.length})`}</span>
{showHistory ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
{showHistory && (
<div className="mt-1 space-y-0.5 pb-4">
{conversations.length === 0 ? (
<p className="text-xs text-zinc-700 text-center py-4"></p>
) : (
conversations.map(conv => (
<ConversationListItem
key={conv.conversationId}
conv={conv}
isActive={activeConvId === conv.conversationId}
onClick={() => handleSelectConversation(conv)}
onDelete={() => deleteConvMutation.mutate({ conversationId: conv.conversationId })}
/>
))
)}
</div>
)}
</div>
</div>
{/*
*/}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 顶部栏 */}
<div className="flex items-center justify-between px-6 py-3.5 border-b border-zinc-800 bg-zinc-900/80 backdrop-blur-sm shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center">
{agentInfo && (() => {
const IconComp = ICON_MAP[agentInfo.icon] || Bot;
return <IconComp className="w-4 h-4 text-white" />;
})()}
</div>
<div>
<p className="text-sm font-semibold text-white">NAC公链AI</p>
<p className="text-xs text-zinc-500">
{agentInfo?.name} · {agentInfo?.desc}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{activeConvId && (
<Button
variant="ghost"
size="sm"
className="text-xs text-zinc-400 hover:text-white gap-1.5 h-8"
onClick={handleNewConversation}
>
<SquarePen className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
{/* 消息区域 */}
<div className="flex-1 overflow-y-auto">
{/* 加载历史 */}
{loadHistoryQuery.isLoading && activeConvId && (
<div className="flex items-center justify-center py-8 text-xs text-zinc-500 gap-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</div>
)}
{/* 欢迎屏幕 */}
{messages.length === 0 && !loadHistoryQuery.isLoading && (
<WelcomeScreen
agentType={selectedAgent}
suggestedQuestions={currentAgent?.suggestedQuestions || []}
onSelect={(q) => {
setInputText(q);
setTimeout(() => textareaRef.current?.focus(), 100);
}}
/>
)}
{/* 消息列表 */}
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} onSourceClick={handleSourceClick} />
))}
{/* AI 思考中 */}
{chatMutation.isPending && (
<div className="flex gap-4 px-4 py-5">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center shrink-0 mt-0.5">
<Bot className="w-4 h-4 text-white" />
</div>
<div className="flex flex-col gap-2">
<span className="text-xs text-zinc-500 font-medium px-1">NAC公链AI</span>
<div className="bg-zinc-800 border border-zinc-700/50 rounded-2xl rounded-tl-sm px-4 py-3 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-zinc-400" />
<span className="text-sm text-zinc-400">...</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div className="shrink-0 px-6 pb-6 pt-4 bg-zinc-900">
<div className="max-w-3xl mx-auto">
<div className="relative bg-zinc-800 border border-zinc-700 rounded-2xl shadow-lg focus-within:border-zinc-500 transition-colors">
<Textarea
ref={textareaRef}
value={inputText}
onChange={e => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`向 NAC公链AI 提问... (Enter 发送Shift+Enter 换行)`}
className="w-full bg-transparent border-0 resize-none text-sm text-zinc-100 placeholder:text-zinc-600 px-4 pt-4 pb-12 min-h-[60px] max-h-[200px] focus-visible:ring-0 focus-visible:ring-offset-0"
disabled={chatMutation.isPending}
/>
<div className="absolute bottom-3 right-3 flex items-center gap-2">
<span className="text-[10px] text-zinc-700">
{inputText.length > 0 ? `${inputText.length}` : ""}
</span>
<button
onClick={handleSend}
disabled={!inputText.trim() || chatMutation.isPending}
className={`w-8 h-8 rounded-lg flex items-center justify-center transition-all ${
inputText.trim() && !chatMutation.isPending
? "bg-indigo-600 hover:bg-indigo-500 text-white shadow-md"
: "bg-zinc-700 text-zinc-600 cursor-not-allowed"
}`}
>
{chatMutation.isPending
? <Loader2 className="w-4 h-4 animate-spin" />
: <Send className="w-4 h-4" />
}
</button>
</div>
</div>
<p className="text-center text-[10px] text-zinc-700 mt-2">
NAC公链AI NAC
</p>
</div>
</div>
</div>
</div>
);
}