/** * MaiSaka 聊天流实时监控组件 * * 通过 WebSocket 实时接收 MaiSaka 推理引擎事件, * 以时间线形式展示聊天流的推理过程。 */ import { Activity, ArrowRight, Bot, Brain, CheckCircle2, ChevronDown, ChevronRight, CircleDot, Clock, Eraser, Gauge, MessageSquare, PauseCircle, Timer, Wrench, XCircle, Zap, } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardHeader, CardTitle } from '@/components/ui/card' import { ScrollArea } from '@/components/ui/scroll-area' import { Separator } from '@/components/ui/separator' import { cn } from '@/lib/utils' import { useCallback, useEffect, useRef, useState } from 'react' import type { CycleEndEvent, CycleStartEvent, MaisakaToolCall, MessageIngestedEvent, PlannerResponseEvent, ReplierResponseEvent, TimingGateResultEvent, ToolExecutionEvent, } from '@/lib/maisaka-monitor-client' import type { SessionInfo, TimelineEntry } from './use-maisaka-monitor' import { useMaisakaMonitor } from './use-maisaka-monitor' // ─── 工具函数 ────────────────────────────────────────────────── function formatMs(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms` return `${(ms / 1000).toFixed(2)}s` } function formatTimestamp(ts: number): string { return new Date(ts * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', }) } function formatRelativeTime(ts: number): string { const diff = Date.now() / 1000 - ts if (diff < 10) return '刚刚' if (diff < 60) return `${Math.round(diff)}秒前` if (diff < 3600) return `${Math.round(diff / 60)}分钟前` return `${Math.round(diff / 3600)}小时前` } // ─── 会话侧边栏 ────────────────────────────────────────────── function SessionSidebar({ sessions, selectedSession, onSelect, }: { sessions: Map selectedSession: string | null onSelect: (id: string) => void }) { const sortedSessions = Array.from(sessions.values()).sort( (a, b) => b.lastActivity - a.lastActivity, ) if (sortedSessions.length === 0) { return (

等待 MaiSaka 会话…

) } return (
{sortedSessions.map((session) => ( ))}
) } // ─── 单条时间线事件渲染 ────────────────────────────────────── function MessageIngestedCard({ data }: { data: MessageIngestedEvent }) { return (
{data.speaker_name} {formatTimestamp(data.timestamp)}

{data.content || '[空消息]'}

) } function CycleStartCard({ data }: { data: CycleStartEvent }) { return (
推理循环 #{data.cycle_id} 回合 {data.round_index + 1}/{data.max_rounds} 上下文 {data.history_count} 条
) } function TimingGateCard({ data }: { data: TimingGateResultEvent }) { const actionConfig: Record = { continue: { label: '继续执行', variant: 'default', icon: ArrowRight }, wait: { label: '等待', variant: 'secondary', icon: PauseCircle }, no_reply: { label: '不回复', variant: 'destructive', icon: XCircle }, } const config = actionConfig[data.action] ?? actionConfig.continue const Icon = config.icon return (
Timing Gate {config.label} {formatMs(data.duration_ms)}
{data.content && ( )}
) } function PlannerResponseCard({ data }: { data: PlannerResponseEvent }) { return (
规划器思考 {formatMs(data.duration_ms)} {data.prompt_tokens}+{data.completion_tokens} tokens
{data.content && ( )} {data.tool_calls.length > 0 && (
{data.tool_calls.map((tc: MaisakaToolCall, idx: number) => ( {tc.name} ))}
)}
) } function ToolExecutionCard({ data }: { data: ToolExecutionEvent }) { return (
{data.tool_name} {data.success ? : } {formatMs(data.duration_ms)}
{Object.keys(data.tool_args).length > 0 && (
{JSON.stringify(data.tool_args, null, 2)}
)} {data.result_summary && ( )}
) } function CycleEndCard({ data }: { data: CycleEndEvent }) { const totalTime = Object.values(data.time_records).reduce((a, b) => a + b, 0) return (
循环结束 总耗时 {formatMs(totalTime * 1000)} {Object.entries(data.time_records).map(([name, duration]) => ( {name}: {formatMs(duration * 1000)} ))} {data.agent_state}
) } // ─── 可折叠文本组件 ──────────────────────────────────────────── function CollapsibleText({ text, maxLines = 4, className, }: { text: string maxLines?: number className?: string }) { const [expanded, setExpanded] = useState(false) const lines = text.split('\n') const needsCollapse = lines.length > maxLines if (!needsCollapse || expanded) { return (

{text}

{needsCollapse && ( )}
) } return (

{lines.slice(0, maxLines).join('\n')}

) } // ─── 回复器响应卡片 ────────────────────────────────────────── function ReplierResponseCard({ data }: { data: ReplierResponseEvent }) { return (
回复器响应 {formatMs(data.duration_ms)} {data.success ? ( 成功 ) : ( 失败 )} {formatTimestamp(data.timestamp)}
{data.content && ( )} {data.reasoning && (
思考过程
)} {(data.prompt_tokens > 0 || data.completion_tokens > 0) && (
{data.model_name && 模型: {data.model_name}} 输入: {data.prompt_tokens} 输出: {data.completion_tokens} 总计: {data.total_tokens}
)}
) } // ─── 时间线入口渲染器 ────────────────────────────────────────── function TimelineEventRenderer({ entry }: { entry: TimelineEntry }) { switch (entry.type) { case 'message.ingested': return case 'cycle.start': return case 'timing_gate.result': return case 'planner.response': return case 'tool.execution': return case 'cycle.end': return case 'replier.response': return // planner.request, replier.request 和 session.start 通常不需要在 timeline 中主要展示 default: return null } } // ─── 主组件 ───────────────────────────────────────────────── export function MaisakaMonitor() { const { timeline, sessions, selectedSession, setSelectedSession, connected, clearTimeline, } = useMaisakaMonitor() const scrollRef = useRef(null) const [autoScroll, setAutoScroll] = useState(true) // 自动滚动到底部 useEffect(() => { if (autoScroll && scrollRef.current) { const viewport = scrollRef.current.querySelector('[data-radix-scroll-area-viewport]') if (viewport) { viewport.scrollTop = viewport.scrollHeight } } }, [timeline, autoScroll]) const handleScroll = useCallback((e: React.UIEvent) => { const target = e.currentTarget.querySelector('[data-radix-scroll-area-viewport]') if (!target) return const { scrollTop, scrollHeight, clientHeight } = target as HTMLElement setAutoScroll(scrollHeight - scrollTop - clientHeight < 80) }, []) // 统计当前会话的各事件类型计数 const stats = { messages: timeline.filter((e) => e.type === 'message.ingested').length, cycles: timeline.filter((e) => e.type === 'cycle.start').length, toolCalls: timeline.filter((e) => e.type === 'tool.execution').length, } return (
{/* 会话侧边栏 */} 聊天流 {connected && ( )} {/* 主时间线区域 */}
{/* 顶部统计栏 */}
{stats.messages} 消息
{stats.cycles} 循环
{stats.toolCalls} 工具调用
{/* 时间线 */}
{timeline.length === 0 ? (

等待 MaiSaka 推理事件…

当 MaiSaka 处理新消息时,推理过程会实时展示在这里

) : ( timeline.map((entry) => { const rendered = if (!rendered) return null return (
{rendered} {entry.type === 'cycle.end' && ( )}
) }) )}
) }