feat(nac-admin): AI问答页面改版为ChatGPT风格对话界面 - NAC公链AI
This commit is contained in:
parent
8d6ada2692
commit
c8e5e7189f
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue