NAC_Blockchain/ops/nac-admin/client/src/pages/AIAgents.tsx

555 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef, useCallback } 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 { Separator } from "@/components/ui/separator";
import { toast } from "sonner";
import {
BookOpen, Shield, Languages, ClipboardCheck,
Send, Bot, User, Loader2, ChevronRight, Sparkles,
AlertCircle, CheckCircle2, Info, MessageSquare,
Plus, Trash2, Clock, ChevronDown, ChevronUp,
ExternalLink,
} 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_COLORS: Record<AgentType, string> = {
knowledge_qa: "text-blue-500",
compliance: "text-red-500",
translation: "text-green-500",
approval_assist: "text-purple-500",
};
// ─── 置信度徽章 ───────────────────────────────────────────────────
function ConfidenceBadge({ confidence }: { confidence: number }) {
const pct = Math.round(confidence * 100);
const color = pct >= 80 ? "text-green-600" : pct >= 60 ? "text-yellow-600" : "text-red-500";
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-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 hover:underline transition-colors cursor-pointer bg-blue-50 dark:bg-blue-950/30 rounded px-1.5 py-0.5 border border-blue-200 dark:border-blue-800"
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={`flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${isUser ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
</div>
<div className={`max-w-[75%] space-y-1 ${isUser ? "items-end" : "items-start"} flex flex-col`}>
<div
className={`rounded-2xl px-4 py-3 text-sm leading-relaxed whitespace-pre-wrap break-words ${
isUser
? "bg-primary text-primary-foreground rounded-tr-sm"
: "bg-muted text-foreground rounded-tl-sm"
}`}
dir={msg.isRTL ? "rtl" : "ltr"}
>
{msg.content}
</div>
{!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-muted-foreground 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-muted-foreground">+{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 cursor-default">{s}</Badge>
))}
</div>
)}
</div>
</div>
);
}
// ─── 会话列表项 ───────────────────────────────────────────────────
function ConversationListItem({
conv,
isActive,
onClick,
onDelete,
}: {
conv: ConversationItem;
isActive: boolean;
onClick: () => void;
onDelete: () => void;
}) {
const IconComp = ICON_MAP[conv.agentType === "knowledge_qa" ? "BookOpen" : conv.agentType === "compliance" ? "Shield" : conv.agentType === "translation" ? "Languages" : "ClipboardCheck"] || Bot;
const colorClass = AGENT_COLORS[conv.agentType] || "text-muted-foreground";
const updatedAt = new Date(conv.updatedAt);
const timeStr = updatedAt.toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
return (
<div
className={`group flex items-start gap-2 rounded-lg p-2.5 cursor-pointer transition-all ${
isActive ? "bg-primary/10 border border-primary/20" : "hover:bg-muted"
}`}
onClick={onClick}
>
<IconComp className={`w-3.5 h-3.5 mt-0.5 shrink-0 ${colorClass}`} />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate leading-snug">{conv.title}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground">{timeStr}</span>
<span className="text-[10px] text-muted-foreground">· {conv.messageCount}</span>
</div>
</div>
<button
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:text-red-500 transition-all shrink-0"
onClick={e => { e.stopPropagation(); onDelete(); }}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
);
}
// ─── 主页面 ───────────────────────────────────────────────────────
export default function AIAgents() {
const [selectedAgent, setSelectedAgent] = useState<AgentType | null>(null);
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 utils = trpc.useUtils();
const [, navigate] = useLocation();
// 处理来源引用点击 - 跳转到知识库并搜索对应条目
const handleSourceClick = (source: string) => {
// 解析来源格式:"CN·RWA·房地产登记规则" -> 提取关键词
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: 30 },
{ enabled: true }
);
// 加载历史消息
const loadHistoryQuery = trpc.aiAgent.loadHistory.useQuery(
{ conversationId: activeConvId!, limit: 50 },
{ enabled: !!activeConvId }
);
// 当加载历史成功时,将消息填充到界面
useEffect(() => {
if (loadHistoryQuery.data?.messages && activeConvId) {
const msgs: Message[] = loadHistoryQuery.data.messages.map(m => ({
role: m.role as "user" | "assistant",
content: m.content,
confidence: m.confidence,
sources: m.sources,
suggestions: m.suggestions,
}));
setMessages(msgs);
}
}, [loadHistoryQuery.data, activeConvId]);
// 删除会话
const deleteConvMutation = trpc.aiAgent.deleteConversation.useMutation({
onSuccess: () => {
toast.success("会话已删除");
refetchConvs();
},
onError: (e) => toast.error(`删除失败: ${e.message}`),
});
// Agent对话mutation
const chatMutation = trpc.aiAgent.chat.useMutation({
onSuccess: (response) => {
setMessages(prev => [...prev, {
role: "assistant",
content: response.message,
confidence: response.confidence,
sources: response.sources,
suggestions: response.suggestions,
isRTL: response.metadata?.isRTL as boolean,
}]);
// 如果是新会话更新activeConvId
if (!activeConvId && response.conversationId) {
setActiveConvId(response.conversationId);
}
// 刷新会话列表
refetchConvs();
},
onError: (error) => {
toast.error(`Agent响应失败: ${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([]); // 先清空等loadHistory加载
};
// 新建会话
const handleNewConversation = () => {
setActiveConvId(null);
setMessages([]);
setInputText("");
};
// 发送消息
const handleSend = () => {
const text = inputText.trim();
if (!text || chatMutation.isPending || !selectedAgent) return;
const userMsg: Message = { role: "user", content: text };
setMessages(prev => [...prev, userMsg]);
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[];
return (
<div className="flex h-full gap-0 overflow-hidden">
{/* ── 左侧Agent选择 + 历史会话 ── */}
<div className="w-72 shrink-0 border-r flex flex-col bg-muted/20">
{/* 顶部状态 */}
<div className="p-4 border-b">
<div className="flex items-center gap-2 mb-1">
<Sparkles className="w-5 h-5 text-primary" />
<h2 className="font-semibold text-sm">AI智能体</h2>
</div>
{statusData && (
<div className={`mt-2 flex items-center gap-1.5 text-xs px-2 py-1 rounded-md ${statusData.configured ? "bg-green-500/10 text-green-600" : "bg-yellow-500/10 text-yellow-600"}`}>
{statusData.configured
? <><CheckCircle2 className="w-3 h-3" />AI服务已就绪 · {statusData.model}</>
: <><AlertCircle className="w-3 h-3" />AI服务未配置</>
}
</div>
)}
</div>
{/* Agent列表 */}
<div className="p-2 space-y-1 border-b">
{agentLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
) : (
agentData?.agents.map((agent) => {
const IconComp = ICON_MAP[agent.icon] || Bot;
const isActive = selectedAgent === agent.type && !activeConvId;
return (
<button
key={agent.type}
onClick={() => handleSelectAgent(agent.type as AgentType)}
className={`w-full text-left rounded-lg p-2.5 transition-all ${
isActive ? "bg-primary text-primary-foreground" : "hover:bg-muted"
}`}
>
<div className="flex items-center gap-2">
<IconComp className="w-4 h-4 shrink-0" />
<span className="text-sm font-medium truncate">{agent.name}</span>
{isActive && <ChevronRight className="w-3 h-3 ml-auto shrink-0" />}
</div>
</button>
);
})
)}
</div>
{/* 历史会话列表 */}
<div className="flex-1 overflow-y-auto flex flex-col">
<button
className="flex items-center justify-between px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowHistory(v => !v)}
>
<span className="flex items-center gap-1.5">
<MessageSquare className="w-3.5 h-3.5" />
{conversations.length > 0 && `(${conversations.length})`}
</span>
{showHistory ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
{showHistory && (
<div className="px-2 pb-2 space-y-0.5">
{conversations.length === 0 ? (
<p className="text-xs text-muted-foreground 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>
{/* 未配置提示 */}
{agentData && !agentData.configured && (
<div className="p-3 border-t">
<div className="bg-yellow-500/10 text-yellow-700 text-xs rounded-lg p-2.5 leading-relaxed">
<AlertCircle className="w-3 h-3 inline mr-1" />
{agentData.configHint}
</div>
</div>
)}
</div>
{/* ── 右侧:对话区域 ── */}
<div className="flex-1 flex flex-col overflow-hidden">
{!selectedAgent ? (
/* 欢迎界面 */
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-4">
<Sparkles className="w-8 h-8 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2">NAC AI智能体系统</h3>
<p className="text-sm text-muted-foreground mb-6">
</p>
<div className="grid grid-cols-2 gap-3">
{agentData?.agents.map((agent) => {
const IconComp = ICON_MAP[agent.icon] || Bot;
return (
<button
key={agent.type}
onClick={() => handleSelectAgent(agent.type as AgentType)}
className="border rounded-xl p-3 text-left hover:border-primary hover:bg-primary/5 transition-all"
>
<IconComp className="w-5 h-5 text-primary mb-2" />
<p className="text-sm font-medium">{agent.name}</p>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{agent.description}</p>
</button>
);
})}
</div>
</div>
</div>
) : (
<>
{/* 对话头部 */}
<div className="border-b px-4 py-3 flex items-center gap-3 bg-background">
{currentAgent && (() => {
const IconComp = ICON_MAP[currentAgent.icon] || Bot;
return (
<>
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
<IconComp className="w-4 h-4 text-primary" />
</div>
<div>
<p className="text-sm font-semibold">{currentAgent.name}</p>
<p className="text-xs text-muted-foreground">
{activeConvId
? `续接历史会话 · ${messages.length}条消息`
: "新对话 · 历史将自动保存"
}
</p>
</div>
</>
);
})()}
<div className="ml-auto flex items-center gap-2">
{activeConvId && (
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground gap-1"
onClick={handleNewConversation}
>
<Plus className="w-3 h-3" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground"
onClick={() => { setMessages([]); setActiveConvId(null); }}
>
</Button>
</div>
</div>
{/* 加载历史中 */}
{loadHistoryQuery.isLoading && activeConvId && (
<div className="flex items-center justify-center py-4 text-xs text-muted-foreground gap-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</div>
)}
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && !loadHistoryQuery.isLoading && currentAgent && (
<div className="text-center py-8">
<p className="text-sm text-muted-foreground mb-4"></p>
<div className="flex flex-wrap gap-2 justify-center">
{currentAgent.suggestedQuestions.map((q, i) => (
<button
key={i}
onClick={() => setInputText(q)}
className="text-xs border rounded-full px-3 py-1.5 hover:bg-muted transition-colors text-left"
>
{q}
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} onSourceClick={handleSourceClick} />
))}
{chatMutation.isPending && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center shrink-0">
<Bot className="w-4 h-4" />
</div>
<div className="bg-muted rounded-2xl rounded-tl-sm px-4 py-3">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div className="border-t p-4 bg-background">
<div className="flex gap-2 items-end">
<Textarea
value={inputText}
onChange={e => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`${currentAgent?.name || "AI助手"}提问... (Enter发送Shift+Enter换行)`}
className="flex-1 min-h-[60px] max-h-[120px] resize-none text-sm"
disabled={chatMutation.isPending}
/>
<Button
onClick={handleSend}
disabled={!inputText.trim() || chatMutation.isPending}
size="icon"
className="h-10 w-10 shrink-0"
>
{chatMutation.isPending
? <Loader2 className="w-4 h-4 animate-spin" />
: <Send className="w-4 h-4" />
}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1.5">
AI回答仅供参考 ·
</p>
</div>
</>
)}
</div>
</div>
);
}