555 lines
22 KiB
TypeScript
555 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|