/** * MaiSaka 聊天流实时监控组件 * * 通过 WebSocket 实时接收 MaiSaka 推理引擎事件, * 以时间线形式展示聊天流的推理过程。 */ import { Activity, AlertCircle, ArrowRight, Bot, Brain, CheckCircle2, ChevronDown, ChevronRight, CircleDot, Clock, Eraser, ExternalLink, Gauge, MessageSquare, PauseCircle, Radio, 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, MessageSentEvent, PlannerFinalizedEvent, PlannerResponseEvent, ReplierResponseEvent, TimingGateResultEvent, ToolExecutionEvent, } from '@/lib/maisaka-monitor-client' import type { SessionInfo, StageStatusInfo, 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 buildCycleKey(sessionId: string, cycleId: number) { return `${sessionId}:${cycleId}` } 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, stageStatuses, selectedSession, onSelect, collapsed, }: { sessions: Map stageStatuses: Map selectedSession: string | null onSelect: (id: string) => void collapsed: boolean }) { const sortedSessions = Array.from(sessions.values()).sort( (a, b) => b.lastActivity - a.lastActivity, ) const getSessionInitial = (session: SessionInfo) => { const name = session.sessionName.trim() if (name) return name.slice(0, 1) return session.isGroupChat ? '群' : '私' } if (sortedSessions.length === 0) { return (

等待 MaiSaka 会话…

) } return (
{sortedSessions.map((session) => { const status = stageStatuses.get(session.sessionId) return ( ) })}
) } // ─── 单条时间线事件渲染 ────────────────────────────────────── function StageStatusPanel({ status }: { status?: StageStatusInfo }) { if (!status) { return (
当前聊天流暂无阶段状态
) } return (
{status.stage || '未知阶段'} {status.roundText && ( {status.roundText} )} {status.agentState && ( {status.agentState} )} 更新于 {formatRelativeTime(status.updatedAt)}
{status.detail && (

{status.detail}

)}
) } function MessageIngestedCard({ data }: { data: MessageIngestedEvent }) { return (
{data.speaker_name} {formatTimestamp(data.timestamp)}

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

) } function MessageSentCard({ data }: { data: MessageSentEvent }) { 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 (
反应 react {config.label} {formatMs(data.duration_ms)}
{data.content && ( )}
) } function ToolCallBadges({ toolCalls }: { toolCalls: MaisakaToolCall[] }) { if (toolCalls.length <= 0) { return null } return (
{toolCalls.map((tc: MaisakaToolCall, idx: number) => ( {tc.name} ))}
) } function openPromptHtml(uri: string) { const normalized = uri.trim() if (!normalized) return window.open(normalized, '_blank', 'noopener,noreferrer') } function isPlannerInterrupted(data: PlannerFinalizedEvent) { const content = data.planner?.content?.trim() ?? '' return data.interrupted === true || ( content.startsWith('Planner ') && data.planner?.prompt_tokens === 0 && data.planner?.completion_tokens === 0 && data.planner?.tool_calls.length === 0 ) } function PlannerInterruptedCard({ data }: { data: PlannerFinalizedEvent }) { const planner = data.planner return (
Planner 被新消息打断 #{data.cycle_id} {planner && planner.duration_ms > 0 && ( {formatMs(planner.duration_ms)} )}

{planner?.content || '收到新消息,已停止当前思考并准备重新决策。'}

) } function PlannerResponseCard({ data }: { data: PlannerResponseEvent }) { return (
规划器思考 {formatMs(data.duration_ms)} {data.prompt_tokens}+{data.completion_tokens} tokens
{data.content && ( )}
) } function PlannerFinalizedCard({ data }: { data: PlannerFinalizedEvent }) { const planner = data.planner const promptHtmlUri = planner?.prompt_html_uri?.trim() ?? '' return (
主循环 planner {promptHtmlUri && ( )} {formatMs(planner?.duration_ms ?? 0)} {data.request && ( 上下文 {data.request.selected_history_count} 条 / 可用工具 {data.request.tool_count} )} {planner && (planner.prompt_tokens > 0 || planner.completion_tokens > 0) && ( {planner.prompt_tokens}+{planner.completion_tokens} tokens )}
{planner?.content ? ( ) : (

planner 本轮没有文本内容

)}
) } function PlannerToolCallsBlock({ data }: { data: PlannerFinalizedEvent }) { const toolCalls = data.planner?.tool_calls ?? [] const tools = data.tools ?? [] const displayTools = tools.length > 0 ? tools : toolCalls.map((toolCall) => ({ tool_call_id: toolCall.id, tool_name: toolCall.name, tool_args: toolCall.arguments ?? {}, success: true, duration_ms: 0, summary: '', })) const isFinishTool = (toolName?: string) => toolName?.trim().toLowerCase() === 'finish' const finishTools = displayTools.filter((tool) => isFinishTool(tool.tool_name)) const regularTools = displayTools.filter((tool) => !isFinishTool(tool.tool_name)) if (displayTools.length <= 0) { return null } if (regularTools.length <= 0 && finishTools.length > 0) { return (
本轮思考暂时结束 等待新的消息。
) } return (
Planner 工具调用 {regularTools.length} 个
{finishTools.length > 0 && (
本轮思考暂时结束 等待新的消息。
)}
{regularTools.map((tool, idx) => (
{tool.tool_name || 'unknown'} {tool.success ? : } {tool.duration_ms > 0 && ( {formatMs(tool.duration_ms)} )}
{Object.keys(tool.tool_args ?? {}).length > 0 && (
                  {JSON.stringify(tool.tool_args, null, 2)}
                
)} {tool.summary && (

{tool.summary}

)}
))}
) } 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 getCycleEndReasonText(data: CycleEndEvent) { const reason = data.end_reason ?? '' const detail = data.end_detail?.trim() if (detail) { return detail } if (reason === 'finish') return 'Planner 调用 finish,结束本轮思考并等待新消息。' if (reason === 'timing_no_reply') return 'Timing Gate 选择 no_reply,本轮不会进入 Planner。' if (reason === 'max_rounds') return '已达到内部思考轮次上限,本轮处理结束。' if (reason === 'planner_interrupted') return 'Planner 被新消息打断,当前轮结束。' if (reason.startsWith('tool_pause:')) return `工具 ${reason.slice('tool_pause:'.length)} 要求暂停当前思考循环。` if (reason === 'tool_pause') return '工具要求暂停当前思考循环。' if (reason === 'empty_planner_response') return 'Planner 没有返回文本或工具调用,本轮思考结束。' if (reason === 'tool_continue') return 'Planner 工具执行完成,继续下一轮内部思考。' return '本轮思考完成。' } function getCycleEndReasonLabel(data: CycleEndEvent) { const reason = data.end_reason ?? '' if (reason === 'finish') return 'finish 结束' if (reason === 'timing_no_reply') return 'no_reply 结束' if (reason === 'max_rounds') return '轮次上限' if (reason === 'planner_interrupted') return 'Planner 打断' if (reason.startsWith('tool_pause:')) return '工具暂停' if (reason === 'tool_pause') return '工具暂停' if (reason === 'empty_planner_response') return '空响应' if (reason === 'tool_continue') return '继续下一轮' return '循环结束' } function CycleEndCard({ data }: { data: CycleEndEvent }) { const totalTime = Object.values(data.time_records).reduce((a, b) => a + b, 0) return (
{getCycleEndReasonLabel(data)} #{data.cycle_id} {formatMs(totalTime * 1000)} {data.agent_state}

{getCycleEndReasonText(data)}

) } // ─── 可折叠文本组件 ──────────────────────────────────────────── 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, showCycleMarkers, }: { entry: TimelineEntry showCycleMarkers: boolean }) { switch (entry.type) { case 'message.ingested': return case 'message.sent': return case 'cycle.start': if (!showCycleMarkers) return null return case 'timing_gate.result': return case 'planner.response': return case 'planner.finalized': if (isPlannerInterrupted(entry.data as PlannerFinalizedEvent)) { return } if ((entry.data as PlannerFinalizedEvent).timing_gate?.result?.action === 'no_reply') { return null } 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, stageStatuses, selectedSession, setSelectedSession, connected, backgroundCollection, setBackgroundCollectionEnabled, clearTimeline, } = useMaisakaMonitor() const scrollRef = useRef(null) const [autoScroll, setAutoScroll] = useState(true) const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { const saved = localStorage.getItem('maisaka-monitor-sidebar-collapsed') return saved !== 'false' }) const [showCycleMarkers, setShowCycleMarkers] = useState(() => { const saved = localStorage.getItem('maisaka-monitor-show-cycle-markers') return saved === 'true' }) useEffect(() => { localStorage.setItem('maisaka-monitor-sidebar-collapsed', String(sidebarCollapsed)) }, [sidebarCollapsed]) useEffect(() => { localStorage.setItem('maisaka-monitor-show-cycle-markers', String(showCycleMarkers)) }, [showCycleMarkers]) // 自动滚动到底部 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' || e.type === 'message.sent').length, cycles: timeline.filter((e) => e.type === 'cycle.start').length, toolCalls: timeline.reduce((count, entry) => { if (entry.type === 'tool.execution') { return count + 1 } if (entry.type === 'planner.finalized') { return count + ((entry.data as PlannerFinalizedEvent).tools?.length ?? 0) } return count }, 0), } const selectedStageStatus = selectedSession ? stageStatuses.get(selectedSession) : undefined return (
{/* 会话侧边栏 */} {!sidebarCollapsed && } 聊天流 {connected && ( )} {/* 主时间线区域 */}
{/* 顶部统计栏 */}
{stats.messages} 消息
{stats.cycles} 循环
{stats.toolCalls} 工具调用
{/* 时间线 */}
{timeline.length === 0 ? (

等待 MaiSaka 推理事件…

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

) : ( (() => { const noReplyTimingGateCycles = new Set() return timeline.map((entry) => { if (entry.type === 'timing_gate.result') { const data = entry.data as TimingGateResultEvent if (data.action === 'no_reply') { noReplyTimingGateCycles.add(buildCycleKey(data.session_id, data.cycle_id)) } } if (entry.type === 'planner.response' || entry.type === 'planner.finalized') { const data = entry.data as PlannerResponseEvent | PlannerFinalizedEvent const cycleKey = buildCycleKey(data.session_id, data.cycle_id) if (entry.type === 'planner.finalized' && isPlannerInterrupted(data as PlannerFinalizedEvent)) { const rendered = if (!rendered) return null return (
{rendered}
) } if (noReplyTimingGateCycles.has(cycleKey)) { return null } } const rendered = if (!rendered) return null return (
{rendered}
) }) })() )}
) }