上传完整的WebUI前端仓库
This commit is contained in:
86
dashboard/src/routes/monitor/index.tsx
Normal file
86
dashboard/src/routes/monitor/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 监控页面主入口
|
||||
* 整合规划器监控和回复器监控
|
||||
*/
|
||||
import { Activity, RefreshCw, MessageSquareText } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { PlannerMonitor } from './planner-monitor'
|
||||
import { ReplierMonitor } from './replier-monitor'
|
||||
|
||||
export function PlannerMonitorPage() {
|
||||
const [activeTab, setActiveTab] = useState<'planner' | 'replier'>('planner')
|
||||
const [autoRefresh, setAutoRefresh] = useState(false)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
setRefreshKey(k => k + 1)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">计划器 & 回复器监控</h1>
|
||||
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base">
|
||||
实时监控麦麦的任务计划器和回复生成器运行状态
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={autoRefresh ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${autoRefresh ? 'animate-spin' : ''}`} />
|
||||
{autoRefresh ? '自动刷新中' : '自动刷新'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleManualRefresh}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as 'planner' | 'replier')}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 gap-0.5 sm:gap-1 h-auto p-1">
|
||||
<TabsTrigger value="planner" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
|
||||
<Activity className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
|
||||
<span>计划器监控</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="replier" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
|
||||
<MessageSquareText className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
|
||||
<span>回复器监控</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-240px)] sm:h-[calc(100vh-280px)] mt-4 sm:mt-6">
|
||||
<TabsContent value="planner" className="mt-0">
|
||||
<PlannerMonitor
|
||||
autoRefresh={autoRefresh}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="replier" className="mt-0">
|
||||
<ReplierMonitor
|
||||
autoRefresh={autoRefresh}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
636
dashboard/src/routes/monitor/planner-monitor.tsx
Normal file
636
dashboard/src/routes/monitor/planner-monitor.tsx
Normal file
@@ -0,0 +1,636 @@
|
||||
/**
|
||||
* 规划器监控组件
|
||||
*/
|
||||
import { Clock, TrendingUp, FileText, Zap, Brain, List, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowLeft, MessageSquare, Search } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
getPlannerOverview,
|
||||
getChatLogs,
|
||||
getLogDetail,
|
||||
type PlannerOverview,
|
||||
type PlanLogDetail,
|
||||
type PaginatedChatLogs,
|
||||
type ChatSummary
|
||||
} from '@/lib/planner-api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useChatNameMap, formatTimestamp, formatRelativeTime, useAutoRefresh } from './use-monitor'
|
||||
|
||||
interface PlannerMonitorProps {
|
||||
autoRefresh: boolean
|
||||
refreshKey: number
|
||||
}
|
||||
|
||||
export function PlannerMonitor({ autoRefresh, refreshKey }: PlannerMonitorProps) {
|
||||
// 视图状态: 'overview' | 'chat-logs'
|
||||
const [view, setView] = useState<'overview' | 'chat-logs'>('overview')
|
||||
const [selectedChat, setSelectedChat] = useState<ChatSummary | null>(null)
|
||||
|
||||
// 聊天名称映射
|
||||
const { getChatName } = useChatNameMap()
|
||||
|
||||
// 总览数据
|
||||
const [overview, setOverview] = useState<PlannerOverview | null>(null)
|
||||
const [overviewLoading, setOverviewLoading] = useState(true)
|
||||
|
||||
// 聊天日志数据
|
||||
const [chatLogs, setChatLogs] = useState<PaginatedChatLogs | null>(null)
|
||||
const [chatLogsLoading, setChatLogsLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [jumpToPage, setJumpToPage] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
|
||||
// 详情弹窗
|
||||
const [selectedLog, setSelectedLog] = useState<PlanLogDetail | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
// 加载总览数据
|
||||
const loadOverview = useCallback(async () => {
|
||||
try {
|
||||
setOverviewLoading(true)
|
||||
const data = await getPlannerOverview()
|
||||
setOverview(data)
|
||||
} catch (error) {
|
||||
console.error('加载规划器总览失败:', error)
|
||||
} finally {
|
||||
setOverviewLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 加载聊天日志
|
||||
const loadChatLogs = useCallback(async () => {
|
||||
if (!selectedChat) return
|
||||
try {
|
||||
setChatLogsLoading(true)
|
||||
const data = await getChatLogs(selectedChat.chat_id, page, pageSize, searchQuery || undefined)
|
||||
setChatLogs(data)
|
||||
} catch (error) {
|
||||
console.error('加载聊天日志失败:', error)
|
||||
} finally {
|
||||
setChatLogsLoading(false)
|
||||
}
|
||||
}, [selectedChat, page, pageSize, searchQuery])
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadOverview()
|
||||
}, [loadOverview])
|
||||
|
||||
// 响应外部刷新
|
||||
useEffect(() => {
|
||||
if (refreshKey > 0) {
|
||||
if (view === 'overview') {
|
||||
loadOverview()
|
||||
} else {
|
||||
loadChatLogs()
|
||||
}
|
||||
}
|
||||
}, [refreshKey, view, loadOverview, loadChatLogs])
|
||||
|
||||
// 加载聊天日志
|
||||
useEffect(() => {
|
||||
if (view === 'chat-logs' && selectedChat) {
|
||||
loadChatLogs()
|
||||
}
|
||||
}, [view, selectedChat, loadChatLogs])
|
||||
|
||||
// 自动刷新
|
||||
useAutoRefresh(
|
||||
autoRefresh,
|
||||
useCallback(() => {
|
||||
if (view === 'overview') {
|
||||
loadOverview()
|
||||
} else {
|
||||
loadChatLogs()
|
||||
}
|
||||
}, [view, loadOverview, loadChatLogs])
|
||||
)
|
||||
|
||||
const handleChatClick = (chat: ChatSummary) => {
|
||||
setSelectedChat(chat)
|
||||
setPage(1)
|
||||
setSearchQuery('')
|
||||
setSearchInput('')
|
||||
setView('chat-logs')
|
||||
}
|
||||
|
||||
const handleBackToOverview = () => {
|
||||
setView('overview')
|
||||
setSelectedChat(null)
|
||||
setChatLogs(null)
|
||||
setSearchQuery('')
|
||||
setSearchInput('')
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearchQuery(searchInput)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchInput('')
|
||||
setSearchQuery('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleLogClick = async (chatId: string, filename: string) => {
|
||||
try {
|
||||
setDetailLoading(true)
|
||||
setDialogOpen(true)
|
||||
const detail = await getLogDetail(chatId, filename)
|
||||
setSelectedLog(detail)
|
||||
} catch (error) {
|
||||
console.error('加载计划详情失败:', error)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (value: string) => {
|
||||
setPageSize(Number(value))
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleJumpToPage = () => {
|
||||
const targetPage = parseInt(jumpToPage)
|
||||
const totalPages = chatLogs ? Math.ceil(chatLogs.total / chatLogs.page_size) : 0
|
||||
if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= totalPages) {
|
||||
setPage(targetPage)
|
||||
setJumpToPage('')
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = chatLogs ? Math.ceil(chatLogs.total / chatLogs.page_size) : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{view === 'overview' ? (
|
||||
// ========== 第一级:总览视图 ==========
|
||||
<>
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">聊天数量</CardTitle>
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{overviewLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{overview?.total_chats || 0}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">计划总数</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{overviewLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{overview?.total_plans || 0}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 聊天卡片列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>聊天列表</CardTitle>
|
||||
<CardDescription>点击查看该聊天的所有计划记录</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{overviewLoading ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : overview?.chats && overview.chats.length > 0 ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{overview.chats.map((chat) => (
|
||||
<div
|
||||
key={chat.chat_id}
|
||||
className="border rounded-lg p-4 hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onClick={() => handleChatClick(chat)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm truncate max-w-[180px]" title={getChatName(chat.chat_id)}>
|
||||
{getChatName(chat.chat_id)}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="secondary">{chat.plan_count}</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
最后活动: {formatRelativeTime(chat.latest_timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">暂无聊天记录</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
// ========== 第二级:聊天日志列表 ==========
|
||||
<>
|
||||
{/* 返回按钮和聊天信息 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-4">
|
||||
<Button variant="outline" size="sm" onClick={handleBackToOverview}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
返回聊天列表
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
当前聊天: <span className="font-medium">{selectedChat ? getChatName(selectedChat.chat_id) : ''}</span>
|
||||
<span className="mx-2">•</span>
|
||||
共 {chatLogs?.total || 0} 条计划记录
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>计划执行记录</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedChat ? getChatName(selectedChat.chat_id) : ''}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
placeholder="搜索提示词内容..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full sm:w-48"
|
||||
/>
|
||||
<Button variant="outline" size="icon" onClick={handleSearch}>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
{searchQuery && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearSearch}>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Select value={pageSize.toString()} onValueChange={handlePageSizeChange}>
|
||||
<SelectTrigger className="w-full sm:w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10条/页</SelectItem>
|
||||
<SelectItem value="20">20条/页</SelectItem>
|
||||
<SelectItem value="50">50条/页</SelectItem>
|
||||
<SelectItem value="100">100条/页</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
搜索关键词: <span className="font-medium">"{searchQuery}"</span>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chatLogsLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : chatLogs?.data && chatLogs.data.length > 0 ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{chatLogs.data.map((plan) => (
|
||||
<div
|
||||
key={plan.filename}
|
||||
className="border rounded-lg p-3 hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onClick={() => handleLogClick(plan.chat_id, plan.filename)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{formatTimestamp(plan.timestamp)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{plan.action_count} 个动作
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{plan.total_plan_ms.toFixed(0)}ms
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{plan.action_types && plan.action_types.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{plan.action_types.map((type, idx) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800">
|
||||
{type}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm line-clamp-2">{plan.reasoning_preview || '无推理内容'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页控件 */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {chatLogs.total} 条记录,第 {page} / {totalPages} 页
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(1)}
|
||||
disabled={page === 1}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={jumpToPage}
|
||||
onChange={(e) => setJumpToPage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleJumpToPage()}
|
||||
placeholder="跳转"
|
||||
className="w-20 h-8"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleJumpToPage}>
|
||||
跳转
|
||||
</Button>
|
||||
</div>
|
||||
<span className="sm:hidden text-sm text-muted-foreground">
|
||||
{page}/{totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(totalPages)}
|
||||
disabled={page === totalPages}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">暂无计划记录</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ========== 第三级:计划详情弹窗 ========== */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] grid grid-rows-[auto_1fr_auto] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
计划执行详情
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
查看麦麦的详细计划推理过程和执行动作
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-full pr-4">
|
||||
<div className="space-y-6 pb-4">
|
||||
{detailLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : selectedLog ? (
|
||||
<>
|
||||
{/* 基本信息 */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
基本信息
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">聊天</div>
|
||||
<div className="text-sm" title={selectedLog.chat_id}>{getChatName(selectedLog.chat_id)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">时间戳</div>
|
||||
<div className="text-sm">{formatTimestamp(selectedLog.timestamp)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">类型</div>
|
||||
<Badge variant="outline">{selectedLog.type}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">动作数量</div>
|
||||
<Badge>{selectedLog.actions.length} 个动作</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 时间统计 */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Zap className="h-4 w-4" />
|
||||
性能统计
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-xs text-muted-foreground">提示词构建</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-xl font-bold">{selectedLog.timing.prompt_build_ms?.toFixed(2) || 0}ms</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-xs text-muted-foreground">LLM 推理</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-xl font-bold">{selectedLog.timing.llm_duration_ms?.toFixed(2) || 0}ms</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-xs text-muted-foreground">总计划时间</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-xl font-bold">{selectedLog.timing.total_plan_ms?.toFixed(2) || 0}ms</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 推理内容 */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Brain className="h-4 w-4" />
|
||||
推理过程
|
||||
</h3>
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{selectedLog.reasoning || '无推理内容'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 执行动作 */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<List className="h-4 w-4" />
|
||||
执行动作 ({selectedLog.actions.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{selectedLog.actions.map((action, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default">动作 {index + 1}</Badge>
|
||||
<Badge variant="outline">{action.action_type}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 space-y-3">
|
||||
{action.reasoning && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">推理依据</div>
|
||||
<p className="text-sm bg-muted/30 p-2 rounded">
|
||||
{typeof action.reasoning === 'string' ? action.reasoning : JSON.stringify(action.reasoning)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{action.action_message && (
|
||||
<div className="overflow-hidden">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">动作消息</div>
|
||||
{typeof action.action_message === 'string' ? (
|
||||
<p className="text-sm bg-muted/30 p-2 rounded break-all whitespace-pre-wrap">{action.action_message}</p>
|
||||
) : (
|
||||
<pre className="text-xs bg-muted/30 p-2 rounded overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(action.action_message, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{action.action_data && Object.keys(action.action_data).length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">动作数据</div>
|
||||
<pre className="text-xs bg-muted/30 p-2 rounded overflow-x-auto">
|
||||
{JSON.stringify(action.action_data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{action.action_reasoning && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">动作推理</div>
|
||||
<p className="text-sm bg-muted/30 p-2 rounded">
|
||||
{typeof action.action_reasoning === 'string' ? action.action_reasoning : JSON.stringify(action.action_reasoning)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 原始输出 */}
|
||||
{selectedLog.raw_output && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">原始输出</h3>
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
|
||||
点击展开查看完整原始输出
|
||||
</summary>
|
||||
<div className="mt-2 p-4 bg-muted/50 rounded-lg">
|
||||
<pre className="text-xs whitespace-pre-wrap break-words">{selectedLog.raw_output}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提示词 */}
|
||||
{selectedLog.prompt && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">完整提示词</h3>
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
|
||||
点击展开查看完整提示词
|
||||
</summary>
|
||||
<div className="mt-2 p-4 bg-muted/50 rounded-lg">
|
||||
<pre className="text-xs whitespace-pre-wrap break-words">{selectedLog.prompt}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">无数据</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<Button onClick={() => setDialogOpen(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
648
dashboard/src/routes/monitor/replier-monitor.tsx
Normal file
648
dashboard/src/routes/monitor/replier-monitor.tsx
Normal file
@@ -0,0 +1,648 @@
|
||||
/**
|
||||
* 回复器监控组件
|
||||
*/
|
||||
import { Clock, TrendingUp, FileText, Zap, Brain, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowLeft, MessageSquare, CheckCircle, XCircle, Cpu, Search } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
getReplierOverview,
|
||||
getReplyChatLogs,
|
||||
getReplyLogDetail,
|
||||
type ReplierOverview,
|
||||
type ReplyLogDetail,
|
||||
type PaginatedReplyLogs,
|
||||
type ReplierChatSummary
|
||||
} from '@/lib/planner-api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useChatNameMap, formatTimestamp, formatRelativeTime, useAutoRefresh } from './use-monitor'
|
||||
|
||||
interface ReplierMonitorProps {
|
||||
autoRefresh: boolean
|
||||
refreshKey: number
|
||||
}
|
||||
|
||||
export function ReplierMonitor({ autoRefresh, refreshKey }: ReplierMonitorProps) {
|
||||
// 视图状态: 'overview' | 'chat-logs'
|
||||
const [view, setView] = useState<'overview' | 'chat-logs'>('overview')
|
||||
const [selectedChat, setSelectedChat] = useState<ReplierChatSummary | null>(null)
|
||||
|
||||
// 聊天名称映射
|
||||
const { getChatName } = useChatNameMap()
|
||||
|
||||
// 总览数据
|
||||
const [overview, setOverview] = useState<ReplierOverview | null>(null)
|
||||
const [overviewLoading, setOverviewLoading] = useState(true)
|
||||
|
||||
// 聊天日志数据
|
||||
const [chatLogs, setChatLogs] = useState<PaginatedReplyLogs | null>(null)
|
||||
const [chatLogsLoading, setChatLogsLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [jumpToPage, setJumpToPage] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
|
||||
// 详情弹窗
|
||||
const [selectedLog, setSelectedLog] = useState<ReplyLogDetail | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
// 加载总览数据
|
||||
const loadOverview = useCallback(async () => {
|
||||
try {
|
||||
setOverviewLoading(true)
|
||||
const data = await getReplierOverview()
|
||||
setOverview(data)
|
||||
} catch (error) {
|
||||
console.error('加载回复器总览失败:', error)
|
||||
} finally {
|
||||
setOverviewLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 加载聊天日志
|
||||
const loadChatLogs = useCallback(async () => {
|
||||
if (!selectedChat) return
|
||||
try {
|
||||
setChatLogsLoading(true)
|
||||
const data = await getReplyChatLogs(selectedChat.chat_id, page, pageSize, searchQuery || undefined)
|
||||
setChatLogs(data)
|
||||
} catch (error) {
|
||||
console.error('加载聊天日志失败:', error)
|
||||
} finally {
|
||||
setChatLogsLoading(false)
|
||||
}
|
||||
}, [selectedChat, page, pageSize, searchQuery])
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadOverview()
|
||||
}, [loadOverview])
|
||||
|
||||
// 响应外部刷新
|
||||
useEffect(() => {
|
||||
if (refreshKey > 0) {
|
||||
if (view === 'overview') {
|
||||
loadOverview()
|
||||
} else {
|
||||
loadChatLogs()
|
||||
}
|
||||
}
|
||||
}, [refreshKey, view, loadOverview, loadChatLogs])
|
||||
|
||||
// 加载聊天日志
|
||||
useEffect(() => {
|
||||
if (view === 'chat-logs' && selectedChat) {
|
||||
loadChatLogs()
|
||||
}
|
||||
}, [view, selectedChat, loadChatLogs])
|
||||
|
||||
// 自动刷新
|
||||
useAutoRefresh(
|
||||
autoRefresh,
|
||||
useCallback(() => {
|
||||
if (view === 'overview') {
|
||||
loadOverview()
|
||||
} else {
|
||||
loadChatLogs()
|
||||
}
|
||||
}, [view, loadOverview, loadChatLogs])
|
||||
)
|
||||
|
||||
const handleChatClick = (chat: ReplierChatSummary) => {
|
||||
setSelectedChat(chat)
|
||||
setPage(1)
|
||||
setSearchQuery('')
|
||||
setSearchInput('')
|
||||
setView('chat-logs')
|
||||
}
|
||||
|
||||
const handleBackToOverview = () => {
|
||||
setView('overview')
|
||||
setSelectedChat(null)
|
||||
setChatLogs(null)
|
||||
setSearchQuery('')
|
||||
setSearchInput('')
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearchQuery(searchInput)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchInput('')
|
||||
setSearchQuery('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleLogClick = async (chatId: string, filename: string) => {
|
||||
try {
|
||||
setDetailLoading(true)
|
||||
setDialogOpen(true)
|
||||
const detail = await getReplyLogDetail(chatId, filename)
|
||||
setSelectedLog(detail)
|
||||
} catch (error) {
|
||||
console.error('加载回复详情失败:', error)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (value: string) => {
|
||||
setPageSize(Number(value))
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleJumpToPage = () => {
|
||||
const targetPage = parseInt(jumpToPage)
|
||||
const totalPages = chatLogs ? Math.ceil(chatLogs.total / chatLogs.page_size) : 0
|
||||
if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= totalPages) {
|
||||
setPage(targetPage)
|
||||
setJumpToPage('')
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = chatLogs ? Math.ceil(chatLogs.total / chatLogs.page_size) : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{view === 'overview' ? (
|
||||
// ========== 第一级:总览视图 ==========
|
||||
<>
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">聊天数量</CardTitle>
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{overviewLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{overview?.total_chats || 0}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">回复总数</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{overviewLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{overview?.total_replies || 0}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 聊天卡片列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>聊天列表</CardTitle>
|
||||
<CardDescription>点击查看该聊天的所有回复记录</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{overviewLoading ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : overview?.chats && overview.chats.length > 0 ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{overview.chats.map((chat) => (
|
||||
<div
|
||||
key={chat.chat_id}
|
||||
className="border rounded-lg p-4 hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onClick={() => handleChatClick(chat)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm truncate max-w-[180px]" title={getChatName(chat.chat_id)}>
|
||||
{getChatName(chat.chat_id)}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="secondary">{chat.reply_count}</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
最后活动: {formatRelativeTime(chat.latest_timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">暂无聊天记录</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
// ========== 第二级:聊天日志列表 ==========
|
||||
<>
|
||||
{/* 返回按钮和聊天信息 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-4">
|
||||
<Button variant="outline" size="sm" onClick={handleBackToOverview}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
返回聊天列表
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
当前聊天: <span className="font-medium">{selectedChat ? getChatName(selectedChat.chat_id) : ''}</span>
|
||||
<span className="mx-2">•</span>
|
||||
共 {chatLogs?.total || 0} 条回复记录
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>回复生成记录</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedChat ? getChatName(selectedChat.chat_id) : ''}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
placeholder="搜索提示词内容..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full sm:w-48"
|
||||
/>
|
||||
<Button variant="outline" size="icon" onClick={handleSearch}>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
{searchQuery && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearSearch}>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Select value={pageSize.toString()} onValueChange={handlePageSizeChange}>
|
||||
<SelectTrigger className="w-full sm:w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10条/页</SelectItem>
|
||||
<SelectItem value="20">20条/页</SelectItem>
|
||||
<SelectItem value="50">50条/页</SelectItem>
|
||||
<SelectItem value="100">100条/页</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
搜索关键词: <span className="font-medium">"{searchQuery}"</span>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chatLogsLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : chatLogs?.data && chatLogs.data.length > 0 ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{chatLogs.data.map((reply) => (
|
||||
<div
|
||||
key={reply.filename}
|
||||
className="border rounded-lg p-3 hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onClick={() => handleLogClick(reply.chat_id, reply.filename)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{formatTimestamp(reply.timestamp)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{reply.success ? (
|
||||
<Badge variant="default" className="text-xs bg-green-600">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
成功
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
失败
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{reply.model}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{reply.overall_ms.toFixed(0)}ms
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm line-clamp-2">{reply.output_preview || '无输出内容'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页控件 */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
共 {chatLogs.total} 条记录,第 {page} / {totalPages} 页
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(1)}
|
||||
disabled={page === 1}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={jumpToPage}
|
||||
onChange={(e) => setJumpToPage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleJumpToPage()}
|
||||
placeholder="跳转"
|
||||
className="w-20 h-8"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleJumpToPage}>
|
||||
跳转
|
||||
</Button>
|
||||
</div>
|
||||
<span className="sm:hidden text-sm text-muted-foreground">
|
||||
{page}/{totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(totalPages)}
|
||||
disabled={page === totalPages}
|
||||
className="hidden sm:flex"
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">暂无回复记录</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ========== 第三级:回复详情弹窗 ========== */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] grid grid-rows-[auto_1fr_auto] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
回复生成详情
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
查看麦麦的详细回复生成过程
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-full pr-4">
|
||||
<div className="space-y-6 pb-4">
|
||||
{detailLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : selectedLog ? (
|
||||
<>
|
||||
{/* 基本信息 */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
基本信息
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">聊天</div>
|
||||
<div className="text-sm" title={selectedLog.chat_id}>{getChatName(selectedLog.chat_id)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">时间戳</div>
|
||||
<div className="text-sm">{formatTimestamp(selectedLog.timestamp)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">状态</div>
|
||||
{selectedLog.success ? (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
成功
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
失败
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">思考深度</div>
|
||||
<Badge variant="outline">Level {selectedLog.think_level}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 模型信息 */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
模型信息
|
||||
</h3>
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<Badge variant="secondary" className="text-sm">{selectedLog.model}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 性能统计 */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Zap className="h-4 w-4" />
|
||||
性能统计
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-xs text-muted-foreground">提示词构建</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-xl font-bold">{selectedLog.timing.prompt_ms?.toFixed(2) || 0}ms</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-xs text-muted-foreground">LLM 推理</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-xl font-bold">{selectedLog.timing.llm_ms?.toFixed(2) || 0}ms</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-xs text-muted-foreground">总耗时</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-xl font-bold">{selectedLog.timing.overall_ms?.toFixed(2) || 0}ms</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 耗时日志 */}
|
||||
{selectedLog.timing.timing_logs && selectedLog.timing.timing_logs.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-muted/30 rounded-lg">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">耗时详情</div>
|
||||
<div className="space-y-1">
|
||||
{selectedLog.timing.timing_logs.map((log, idx) => (
|
||||
<div key={idx} className="text-xs text-muted-foreground">{log}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 几乎为零的耗时 */}
|
||||
{selectedLog.timing.almost_zero && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium">近乎零耗时: </span>
|
||||
{selectedLog.timing.almost_zero}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 回复输出 */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Brain className="h-4 w-4" />
|
||||
回复输出
|
||||
</h3>
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{selectedLog.output || '无输出内容'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 处理后的输出 */}
|
||||
{selectedLog.processed_output && selectedLog.processed_output.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">处理后的输出</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedLog.processed_output.map((output, idx) => (
|
||||
<div key={idx} className="p-3 bg-muted/30 rounded-lg">
|
||||
<p className="text-sm whitespace-pre-wrap">{output}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 推理内容 */}
|
||||
{selectedLog.reasoning && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">推理过程</h3>
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{selectedLog.reasoning}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
{selectedLog.error && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-destructive">错误信息</h3>
|
||||
<div className="p-4 bg-destructive/10 rounded-lg border border-destructive/20">
|
||||
<p className="text-sm text-destructive whitespace-pre-wrap">{selectedLog.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 提示词 */}
|
||||
{selectedLog.prompt && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">完整提示词</h3>
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
|
||||
点击展开查看完整提示词
|
||||
</summary>
|
||||
<div className="mt-2 p-4 bg-muted/50 rounded-lg">
|
||||
<pre className="text-xs whitespace-pre-wrap break-words">{selectedLog.prompt}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">无数据</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<Button onClick={() => setDialogOpen(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
78
dashboard/src/routes/monitor/use-monitor.ts
Normal file
78
dashboard/src/routes/monitor/use-monitor.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 监控页面共享工具和钩子
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { getChatList } from '@/lib/expression-api'
|
||||
import type { ChatInfo } from '@/types/expression'
|
||||
|
||||
/**
|
||||
* 聊天名称映射 Hook
|
||||
* 从表达方式 API 获取聊天列表,构建 chat_id -> chat_name 映射
|
||||
*/
|
||||
export function useChatNameMap() {
|
||||
const [chatNameMap, setChatNameMap] = useState<Map<string, string>>(new Map())
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadChatNameMap = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await getChatList()
|
||||
if (response?.data) {
|
||||
const nameMap = new Map<string, string>()
|
||||
response.data.forEach((chat: ChatInfo) => {
|
||||
nameMap.set(chat.chat_id, chat.chat_name)
|
||||
})
|
||||
setChatNameMap(nameMap)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载聊天列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadChatNameMap()
|
||||
}, [loadChatNameMap])
|
||||
|
||||
const getChatName = useCallback((chatId: string): string => {
|
||||
return chatNameMap.get(chatId) || chatId
|
||||
}, [chatNameMap])
|
||||
|
||||
return { chatNameMap, getChatName, loading, reload: loadChatNameMap }
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间戳为本地时间字符串
|
||||
*/
|
||||
export function formatTimestamp(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间戳为相对时间
|
||||
*/
|
||||
export function formatRelativeTime(timestamp: number): string {
|
||||
const now = Date.now() / 1000
|
||||
const diff = now - timestamp
|
||||
if (diff < 60) return '刚刚'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
|
||||
return `${Math.floor(diff / 86400)} 天前`
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动刷新 Hook
|
||||
*/
|
||||
export function useAutoRefresh(
|
||||
enabled: boolean,
|
||||
callback: () => void,
|
||||
interval: number = 10000
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const timer = setInterval(callback, interval)
|
||||
return () => clearInterval(timer)
|
||||
}, [enabled, callback, interval])
|
||||
}
|
||||
Reference in New Issue
Block a user