using System.Runtime.InteropServices; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Interop; using System.Windows.Media; using System.Windows.Shapes; using AxCopilot.Services.Agent; namespace AxCopilot.Views; /// /// 에이전트 워크플로우 분석기. /// 에이전트 실행 과정을 세로 타임라인 형태로 실시간 시각화합니다. /// 개발자 모드 → 워크플로우 시각화 설정이 켜져있을 때 자동으로 열립니다. /// public partial class WorkflowAnalyzerWindow : Window { private readonly DateTime _startTime = DateTime.Now; private int _totalToolCalls; private int _totalInputTokens; private int _totalOutputTokens; private int _maxIteration; private int _successCount; private int _failCount; private string _logLevel = "simple"; private bool _autoScroll = true; private string _activeTab = "timeline"; // timeline | bottleneck // 병목 분석 데이터 수집 private readonly List _waterfallEntries = new(); private readonly Dictionary _toolTimeAccum = new(); private readonly List<(int Iteration, int Input, int Output)> _tokenTrend = new(); private long _lastEventMs; // 워터폴 시작 시간 기준 // ─── 상하좌우 리사이즈 (WindowStyle=None 대응) ───────────── [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); private const int WM_NCHITTEST = 0x0084; private const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13, HTTOPRIGHT = 14; private const int HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17; public WorkflowAnalyzerWindow() { InitializeComponent(); SourceInitialized += (_, _) => { var hwndSource = (HwndSource)PresentationSource.FromVisual(this); hwndSource?.AddHook(WndProc); }; // 로그 레벨 표시 var app = System.Windows.Application.Current as App; _logLevel = app?.SettingsService?.Settings.Llm.AgentLogLevel ?? "simple"; LogLevelBadge.Text = _logLevel switch { "debug" => "DEBUG", "detailed" => "DETAILED", _ => "SIMPLE", }; // 시작 위치: 화면 우측 하단 Loaded += (_, _) => { var screen = SystemParameters.WorkArea; Left = screen.Right - ActualWidth - 20; Top = screen.Bottom - ActualHeight - 20; UpdateTabVisuals(); }; // 자동 스크롤 감지 TimelineScroller.ScrollChanged += (_, e) => { _autoScroll = Math.Abs(e.ExtentHeight - e.ViewportHeight - e.VerticalOffset) < 30; }; } /// 에이전트 이벤트를 수신하여 타임라인에 추가합니다. public void OnAgentEvent(AgentEvent evt) { if (!Dispatcher.CheckAccess()) { Dispatcher.Invoke(() => OnAgentEvent(evt)); return; } // 요약 카드 업데이트 if (evt.Iteration > _maxIteration) _maxIteration = evt.Iteration; if (evt.InputTokens > 0) _totalInputTokens += evt.InputTokens; if (evt.OutputTokens > 0) _totalOutputTokens += evt.OutputTokens; if (evt.Type is AgentEventType.ToolCall) _totalToolCalls++; if (evt.Type is AgentEventType.ToolResult) _successCount++; if (evt.Type is AgentEventType.Error) _failCount++; UpdateSummaryCards(); // ── 병목 분석 데이터 수집 ── CollectBottleneckData(evt); // 타임라인 노드 추가 var node = CreateTimelineNode(evt); TimelinePanel.Children.Add(node); // 자동 스크롤 if (_autoScroll) TimelineScroller.ScrollToEnd(); // 상태 바 StatusText.Text = evt.Type switch { AgentEventType.Complete => "✓ 완료", AgentEventType.Error => $"⚠ 오류: {Truncate(evt.Summary, 60)}", AgentEventType.ToolCall => $"실행 중: {evt.ToolName}", AgentEventType.Thinking => "사고 중...", AgentEventType.Planning => "계획 수립 중...", _ => StatusText.Text, }; // 완료 시 병목 분석 차트 자동 갱신 if (evt.Type == AgentEventType.Complete) RenderBottleneckCharts(); } /// 타임라인을 초기화합니다. public void Reset() { TimelinePanel.Children.Clear(); DetailPanel.Visibility = Visibility.Collapsed; _totalToolCalls = 0; _totalInputTokens = 0; _totalOutputTokens = 0; _maxIteration = 0; _successCount = 0; _failCount = 0; _waterfallEntries.Clear(); _toolTimeAccum.Clear(); _tokenTrend.Clear(); _lastEventMs = 0; UpdateSummaryCards(); ClearBottleneckCharts(); StatusText.Text = "대기 중..."; } /// 타임라인 탭을 활성화합니다. public void SwitchToTimelineTab() { _activeTab = "timeline"; UpdateTabVisuals(); } /// 병목 분석 탭을 활성화합니다. 외부(ChatWindow)에서 호출합니다. public void SwitchToBottleneckTab() { _activeTab = "bottleneck"; UpdateTabVisuals(); RenderBottleneckCharts(); } // ─── 탭 전환 ────────────────────────────────────────────────── private void TabTimeline_Click(object sender, MouseButtonEventArgs e) { _activeTab = "timeline"; UpdateTabVisuals(); } private void TabBottleneck_Click(object sender, MouseButtonEventArgs e) { _activeTab = "bottleneck"; UpdateTabVisuals(); RenderBottleneckCharts(); } private void UpdateTabVisuals() { var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF)); var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var isTimeline = _activeTab == "timeline"; TabTimeline.Background = isTimeline ? accentBrush : itemBg; TabBottleneck.Background = !isTimeline ? accentBrush : itemBg; // 활성 탭: 흰색 텍스트 (AccentColor 배경 위 가시성), 비활성: PrimaryText TabTimelineIcon.Foreground = isTimeline ? Brushes.White : primaryText; TabTimelineText.Foreground = isTimeline ? Brushes.White : primaryText; TabBottleneckIcon.Foreground = !isTimeline ? Brushes.White : primaryText; TabBottleneckText.Foreground = !isTimeline ? Brushes.White : primaryText; TimelineScroller.Visibility = isTimeline ? Visibility.Visible : Visibility.Collapsed; BottleneckScroller.Visibility = !isTimeline ? Visibility.Visible : Visibility.Collapsed; DetailPanel.Visibility = isTimeline && DetailPanel.Tag != null ? Visibility.Visible : Visibility.Collapsed; } // ─── 병목 분석 데이터 수집 ────────────────────────────────────── private record WaterfallEntry(string Label, long StartMs, long DurationMs, string Type, bool IsBottleneck = false); private void CollectBottleneckData(AgentEvent evt) { var currentMs = (long)(evt.Timestamp - _startTime).TotalMilliseconds; // 워터폴: LLM Thinking과 ToolResult (소요시간이 있는 이벤트) if (evt.Type == AgentEventType.Thinking && evt.Iteration > 0) { // LLM 호출 시작 기록 (종료 시점은 다음 ToolCall/Complete 이벤트에서 계산) _lastEventMs = currentMs; } else if (evt.Type == AgentEventType.ToolCall) { // 이전 LLM 사고 시간 = 현재 - 마지막 이벤트 if (_lastEventMs > 0) { var llmDuration = currentMs - _lastEventMs; if (llmDuration > 100) // 100ms 미만은 노이즈 { _waterfallEntries.Add(new WaterfallEntry( $"LLM #{evt.Iteration}", _lastEventMs, llmDuration, "llm")); } } _lastEventMs = currentMs; } else if (evt.Type == AgentEventType.ToolResult && evt.ElapsedMs > 0) { // 도구 실행 시간 var toolStart = currentMs - evt.ElapsedMs; _waterfallEntries.Add(new WaterfallEntry( evt.ToolName, toolStart, evt.ElapsedMs, "tool")); // 도구별 누적 var key = evt.ToolName; _toolTimeAccum[key] = _toolTimeAccum.GetValueOrDefault(key) + evt.ElapsedMs; _lastEventMs = currentMs; } else if (evt.Type == AgentEventType.Complete && _lastEventMs > 0) { // 마지막 LLM 사고 시간 var llmDuration = currentMs - _lastEventMs; if (llmDuration > 100) { _waterfallEntries.Add(new WaterfallEntry( $"LLM (마무리)", _lastEventMs, llmDuration, "llm")); } } // 토큰 추세 if ((evt.InputTokens > 0 || evt.OutputTokens > 0) && evt.Iteration > 0) { // 같은 반복의 기존 항목이 있으면 업데이트 var idx = _tokenTrend.FindIndex(t => t.Iteration == evt.Iteration); if (idx >= 0) { var old = _tokenTrend[idx]; _tokenTrend[idx] = (evt.Iteration, old.Input + evt.InputTokens, old.Output + evt.OutputTokens); } else { _tokenTrend.Add((evt.Iteration, evt.InputTokens, evt.OutputTokens)); } } } // ─── 병목 분석 차트 렌더링 ───────────────────────────────────── private void ClearBottleneckCharts() { WaterfallCanvas.Children.Clear(); ToolTimePanel.Children.Clear(); TokenTrendCanvas.Children.Clear(); } private void RenderBottleneckCharts() { ClearBottleneckCharts(); RenderWaterfallChart(); RenderToolTimeChart(); RenderTokenTrendChart(); } /// 워터폴 차트: LLM 호출과 도구 실행을 시간 순서로 표시. 병목 구간은 빨간색. private void RenderWaterfallChart() { if (_waterfallEntries.Count == 0) { WaterfallCanvas.Height = 40; var emptyMsg = new TextBlock { Text = "실행 데이터가 없습니다", FontSize = 11, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, }; Canvas.SetLeft(emptyMsg, 10); Canvas.SetTop(emptyMsg, 10); WaterfallCanvas.Children.Add(emptyMsg); return; } // 병목 판정: 평균 × 2.0 이상 = 빨강, × 1.5 이상 = 주황 var avgDuration = _waterfallEntries.Average(e => e.DurationMs); var maxEndMs = _waterfallEntries.Max(e => e.StartMs + e.DurationMs); if (maxEndMs <= 0) maxEndMs = 1; var rowHeight = 22; var labelWidth = 90.0; var chartHeight = _waterfallEntries.Count * rowHeight + 10; WaterfallCanvas.Height = Math.Max(chartHeight, 60); var canvasWidth = WaterfallCanvas.ActualWidth > 0 ? WaterfallCanvas.ActualWidth : 480; var barAreaWidth = canvasWidth - labelWidth - 60; // 우측에 시간 텍스트 공간 for (int i = 0; i < _waterfallEntries.Count; i++) { var entry = _waterfallEntries[i]; var y = i * rowHeight + 4; // 색상 결정 Color barColor; if (entry.DurationMs >= avgDuration * 2.0) barColor = Color.FromRgb(0xEF, 0x44, 0x44); // 빨강 (병목) else if (entry.DurationMs >= avgDuration * 1.5) barColor = Color.FromRgb(0xF9, 0x73, 0x16); // 주황 (주의) else if (entry.Type == "llm") barColor = Color.FromRgb(0x60, 0xA5, 0xFA); // 파랑 (LLM) else barColor = Color.FromRgb(0x34, 0xD3, 0x99); // 녹색 (도구) // 라벨 var label = new TextBlock { Text = Truncate(entry.Label, 12), FontSize = 10, FontFamily = new FontFamily("Consolas"), Foreground = new SolidColorBrush(barColor), Width = labelWidth, TextAlignment = TextAlignment.Right, }; Canvas.SetLeft(label, 0); Canvas.SetTop(label, y + 2); WaterfallCanvas.Children.Add(label); // 바 var barStart = (entry.StartMs / (double)maxEndMs) * barAreaWidth; var barWidth = Math.Max((entry.DurationMs / (double)maxEndMs) * barAreaWidth, 3); var bar = new Rectangle { Width = barWidth, Height = 14, RadiusX = 3, RadiusY = 3, Fill = new SolidColorBrush(barColor), Opacity = 0.85, ToolTip = $"{entry.Label}: {FormatMs(entry.DurationMs)}", }; Canvas.SetLeft(bar, labelWidth + 8 + barStart); Canvas.SetTop(bar, y + 1); WaterfallCanvas.Children.Add(bar); // 시간 텍스트 var timeText = new TextBlock { Text = FormatMs(entry.DurationMs), FontSize = 9, Foreground = new SolidColorBrush(barColor), FontFamily = new FontFamily("Consolas"), }; Canvas.SetLeft(timeText, labelWidth + 8 + barStart + barWidth + 4); Canvas.SetTop(timeText, y + 3); WaterfallCanvas.Children.Add(timeText); // 병목 아이콘 if (entry.DurationMs >= avgDuration * 2.0) { var icon = new TextBlock { Text = "🔴", FontSize = 9, }; Canvas.SetLeft(icon, labelWidth + 8 + barStart + barWidth + 44); Canvas.SetTop(icon, y + 2); WaterfallCanvas.Children.Add(icon); } } } /// 도구별 누적 소요시간 수평 바 차트. private void RenderToolTimeChart() { ToolTimePanel.Children.Clear(); if (_toolTimeAccum.Count == 0) { ToolTimePanel.Children.Add(new TextBlock { Text = "도구 실행 데이터가 없습니다", FontSize = 11, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, Margin = new Thickness(0, 4, 0, 4), }); return; } // LLM 시간 합산 var llmTime = _waterfallEntries .Where(e => e.Type == "llm") .Sum(e => e.DurationMs); var allEntries = _toolTimeAccum .Select(kv => (Name: kv.Key, Ms: kv.Value)) .ToList(); if (llmTime > 0) allEntries.Add(("LLM 호출", llmTime)); var sorted = allEntries.OrderByDescending(e => e.Ms).ToList(); var maxMs = sorted.Max(e => e.Ms); if (maxMs <= 0) maxMs = 1; foreach (var (name, ms) in sorted) { var isBottleneck = ms == sorted[0].Ms; var barColor = name == "LLM 호출" ? Color.FromRgb(0x60, 0xA5, 0xFA) : isBottleneck ? Color.FromRgb(0xEF, 0x44, 0x44) : Color.FromRgb(0x34, 0xD3, 0x99); var row = new Grid { Margin = new Thickness(0, 2, 0, 2) }; row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(90) }); row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(60) }); // 도구명 var nameText = new TextBlock { Text = Truncate(name, 12), FontSize = 11, FontFamily = new FontFamily("Consolas"), Foreground = new SolidColorBrush(barColor), TextAlignment = TextAlignment.Right, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), }; Grid.SetColumn(nameText, 0); // 바 var barWidth = (ms / (double)maxMs); var barBorder = new Border { Background = new SolidColorBrush(barColor), CornerRadius = new CornerRadius(3), Height = 14, HorizontalAlignment = HorizontalAlignment.Left, Width = 0, // 나중에 SizeChanged에서 설정 Opacity = 0.8, }; var barContainer = new Border { Margin = new Thickness(0, 2, 0, 2) }; barContainer.Child = barBorder; barContainer.SizeChanged += (_, _) => { barBorder.Width = Math.Max(barContainer.ActualWidth * barWidth, 4); }; Grid.SetColumn(barContainer, 1); // 시간 var timeText = new TextBlock { Text = FormatMs(ms), FontSize = 10, Foreground = new SolidColorBrush(barColor), FontFamily = new FontFamily("Consolas"), VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(4, 0, 0, 0), }; Grid.SetColumn(timeText, 2); row.Children.Add(nameText); row.Children.Add(barContainer); row.Children.Add(timeText); ToolTimePanel.Children.Add(row); } } /// 반복별 토큰 추세 꺾은선 그래프. private void RenderTokenTrendChart() { TokenTrendCanvas.Children.Clear(); if (_tokenTrend.Count < 2) { TokenTrendCanvas.Height = 40; TokenTrendCanvas.Children.Add(new TextBlock { Text = _tokenTrend.Count == 0 ? "토큰 데이터가 없습니다" : "2회 이상 반복이 필요합니다", FontSize = 11, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, }); Canvas.SetLeft(TokenTrendCanvas.Children[0], 10); Canvas.SetTop(TokenTrendCanvas.Children[0], 10); return; } TokenTrendCanvas.Height = 100; var canvasWidth = TokenTrendCanvas.ActualWidth > 0 ? TokenTrendCanvas.ActualWidth : 480; var canvasHeight = 90.0; var marginLeft = 45.0; var marginRight = 10.0; var chartWidth = canvasWidth - marginLeft - marginRight; var sorted = _tokenTrend.OrderBy(t => t.Iteration).ToList(); var maxToken = sorted.Max(t => Math.Max(t.Input, t.Output)); if (maxToken <= 0) maxToken = 1; // Y축 눈금 for (int i = 0; i <= 2; i++) { var y = canvasHeight * (1 - i / 2.0); var val = maxToken * i / 2; var gridLine = new Line { X1 = marginLeft, Y1 = y, X2 = canvasWidth - marginRight, Y2 = y, Stroke = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, StrokeThickness = 0.5, Opacity = 0.4, }; TokenTrendCanvas.Children.Add(gridLine); var yLabel = new TextBlock { Text = val >= 1000 ? $"{val / 1000.0:F0}k" : val.ToString(), FontSize = 9, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, TextAlignment = TextAlignment.Right, Width = marginLeft - 6, }; Canvas.SetLeft(yLabel, 0); Canvas.SetTop(yLabel, y - 6); TokenTrendCanvas.Children.Add(yLabel); } // 입력 토큰 선 (파랑) DrawPolyline(sorted.Select((t, i) => new Point( marginLeft + (i / (double)(sorted.Count - 1)) * chartWidth, canvasHeight * (1 - t.Input / (double)maxToken) )).ToList(), "#3B82F6"); // 출력 토큰 선 (녹색) DrawPolyline(sorted.Select((t, i) => new Point( marginLeft + (i / (double)(sorted.Count - 1)) * chartWidth, canvasHeight * (1 - t.Output / (double)maxToken) )).ToList(), "#10B981"); // X축 라벨 foreach (var (iter, _, _) in sorted) { var idx = sorted.FindIndex(t => t.Iteration == iter); var x = marginLeft + (idx / (double)(sorted.Count - 1)) * chartWidth; var xLabel = new TextBlock { Text = $"#{iter}", FontSize = 9, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, }; Canvas.SetLeft(xLabel, x - 8); Canvas.SetTop(xLabel, canvasHeight + 2); TokenTrendCanvas.Children.Add(xLabel); } // 범례 var legend = new StackPanel { Orientation = Orientation.Horizontal }; legend.Children.Add(CreateLegendDot("#3B82F6", "입력")); legend.Children.Add(CreateLegendDot("#10B981", "출력")); Canvas.SetRight(legend, 10); Canvas.SetTop(legend, 0); TokenTrendCanvas.Children.Add(legend); // 컨텍스트 폭발 경고 if (sorted.Count >= 3) { var inputValues = sorted.Select(t => t.Input).ToList(); bool increasing = true; for (int i = 1; i < inputValues.Count; i++) { if (inputValues[i] <= inputValues[i - 1]) { increasing = false; break; } } if (increasing && inputValues[^1] > inputValues[0] * 2) { var warning = new TextBlock { Text = "⚠ 컨텍스트 크기 급증", FontSize = 10, Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), FontWeight = FontWeights.SemiBold, }; Canvas.SetLeft(warning, marginLeft + 4); Canvas.SetTop(warning, 0); TokenTrendCanvas.Children.Add(warning); } } } private void DrawPolyline(List points, string colorHex) { if (points.Count < 2) return; var color = (Color)ColorConverter.ConvertFromString(colorHex); var polyline = new Polyline { Stroke = new SolidColorBrush(color), StrokeThickness = 2, StrokeLineJoin = PenLineJoin.Round, }; foreach (var p in points) polyline.Points.Add(p); TokenTrendCanvas.Children.Add(polyline); // 점 찍기 foreach (var p in points) { var dot = new Ellipse { Width = 6, Height = 6, Fill = new SolidColorBrush(color), }; Canvas.SetLeft(dot, p.X - 3); Canvas.SetTop(dot, p.Y - 3); TokenTrendCanvas.Children.Add(dot); } } private static StackPanel CreateLegendDot(string colorHex, string text) { var color = (Color)ColorConverter.ConvertFromString(colorHex); var sp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 0, 0, 0) }; sp.Children.Add(new Ellipse { Width = 6, Height = 6, Fill = new SolidColorBrush(color), VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 3, 0), }); sp.Children.Add(new TextBlock { Text = text, FontSize = 9, Foreground = new SolidColorBrush(color), VerticalAlignment = VerticalAlignment.Center, }); return sp; } // ─── 타임라인 노드 생성 ──────────────────────────────────────── private Border CreateTimelineNode(AgentEvent evt) { var (icon, iconColor, label) = GetEventVisual(evt); var node = new Border { Background = Brushes.Transparent, Margin = new Thickness(0, 1, 0, 1), Padding = new Thickness(8, 6, 8, 6), CornerRadius = new CornerRadius(8), Cursor = Cursors.Hand, Tag = evt, }; var grid = new Grid(); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(3) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // 아이콘 원 var iconBorder = new Border { Width = 22, Height = 22, CornerRadius = new CornerRadius(11), Background = new SolidColorBrush(iconColor), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 2, 0, 0), }; iconBorder.Child = new TextBlock { Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 10, Foreground = Brushes.White, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, }; Grid.SetColumn(iconBorder, 0); grid.Children.Add(iconBorder); // 타임라인 세로 선 var line = new Rectangle { Width = 2, Fill = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Stretch, Opacity = 0.4, }; Grid.SetColumn(line, 1); grid.Children.Add(line); // 내용 var content = new StackPanel { Margin = new Thickness(8, 0, 0, 0) }; var headerPanel = new StackPanel { Orientation = Orientation.Horizontal }; headerPanel.Children.Add(new TextBlock { Text = label, FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, VerticalAlignment = VerticalAlignment.Center, }); if (evt.ElapsedMs > 0) { headerPanel.Children.Add(new TextBlock { Text = evt.ElapsedMs >= 1000 ? $" {evt.ElapsedMs / 1000.0:F1}s" : $" {evt.ElapsedMs}ms", FontSize = 10, Foreground = evt.ElapsedMs > 3000 ? Brushes.OrangeRed : TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, VerticalAlignment = VerticalAlignment.Center, }); } headerPanel.Children.Add(new TextBlock { Text = $" {evt.Timestamp:HH:mm:ss}", FontSize = 9, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, VerticalAlignment = VerticalAlignment.Center, Opacity = 0.6, }); content.Children.Add(headerPanel); if (!string.IsNullOrEmpty(evt.Summary)) { content.Children.Add(new TextBlock { Text = Truncate(evt.Summary, 120), FontSize = 11, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 2, 0, 0), LineHeight = 16, }); } if (evt.InputTokens > 0 || evt.OutputTokens > 0) { var tokenPanel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 0) }; tokenPanel.Children.Add(CreateBadge($"↑{evt.InputTokens:#,0}", "#3B82F6")); tokenPanel.Children.Add(CreateBadge($"↓{evt.OutputTokens:#,0}", "#10B981")); content.Children.Add(tokenPanel); } Grid.SetColumn(content, 2); grid.Children.Add(content); node.Child = grid; var hoverBrush = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); node.MouseEnter += (_, _) => node.Background = hoverBrush; node.MouseLeave += (_, _) => node.Background = Brushes.Transparent; node.MouseLeftButtonUp += (_, _) => ShowDetail(evt); return node; } private static (string Icon, Color Color, string Label) GetEventVisual(AgentEvent evt) { return evt.Type switch { AgentEventType.Thinking => ("\uE7BA", Color.FromRgb(0x60, 0xA5, 0xFA), "사고"), AgentEventType.Planning => ("\uE9D5", Color.FromRgb(0xA7, 0x8B, 0xFA), "계획"), AgentEventType.StepStart => ("\uE72A", Color.FromRgb(0x60, 0xA5, 0xFA), $"단계 {evt.StepCurrent}/{evt.StepTotal}"), AgentEventType.StepDone => ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"단계 완료"), AgentEventType.ToolCall => ("\uE756", Color.FromRgb(0xFB, 0xBF, 0x24), evt.ToolName), AgentEventType.ToolResult=> ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"{evt.ToolName} ✓"), AgentEventType.SkillCall => ("\uE768", Color.FromRgb(0xA7, 0x8B, 0xFA), $"스킬: {evt.ToolName}"), AgentEventType.Error => ("\uE783", Color.FromRgb(0xF8, 0x71, 0x71), $"{evt.ToolName} ✗"), AgentEventType.Complete => ("\uE930", Color.FromRgb(0x34, 0xD3, 0x99), "완료"), AgentEventType.Decision => ("\uE8C8", Color.FromRgb(0xFB, 0xBF, 0x24), "의사결정"), _ => ("\uE946", Color.FromRgb(0x6B, 0x72, 0x80), "이벤트"), }; } private static Border CreateBadge(string text, string colorHex) { var color = (Color)ColorConverter.ConvertFromString(colorHex); return new Border { Background = new SolidColorBrush(Color.FromArgb(0x30, color.R, color.G, color.B)), CornerRadius = new CornerRadius(4), Padding = new Thickness(5, 1, 5, 1), Margin = new Thickness(0, 0, 4, 0), Child = new TextBlock { Text = text, FontSize = 9, Foreground = new SolidColorBrush(color), FontWeight = FontWeights.SemiBold, }, }; } // ─── 상세 패널 ───────────────────────────────────────────── private void ShowDetail(AgentEvent evt) { DetailPanel.Visibility = Visibility.Visible; DetailPanel.Tag = evt; var (_, color, label) = GetEventVisual(evt); DetailTitle.Text = label; DetailBadge.Text = evt.Success ? "성공" : "실패"; DetailBadge.Background = new SolidColorBrush(evt.Success ? Color.FromRgb(0x34, 0xD3, 0x99) : Color.FromRgb(0xF8, 0x71, 0x71)); var meta = $"시간: {evt.Timestamp:HH:mm:ss.fff}"; if (evt.ElapsedMs > 0) meta += $" | 소요: {(evt.ElapsedMs >= 1000 ? $"{evt.ElapsedMs / 1000.0:F1}s" : $"{evt.ElapsedMs}ms")}"; if (evt.InputTokens > 0 || evt.OutputTokens > 0) meta += $" | 토큰: 입력 {evt.InputTokens:#,0} / 출력 {evt.OutputTokens:#,0}"; if (evt.Iteration > 0) meta += $" | 반복 #{evt.Iteration}"; DetailMeta.Text = meta; var contentText = evt.Summary ?? ""; if (!string.IsNullOrEmpty(evt.ToolInput)) contentText += $"\n\n파라미터:\n{Truncate(evt.ToolInput, 500)}"; if (!string.IsNullOrEmpty(evt.FilePath)) contentText += $"\n파일: {evt.FilePath}"; DetailContent.Text = contentText; } // ─── 요약 카드 업데이트 ────────────────────────────────────── private void UpdateSummaryCards() { var elapsed = (DateTime.Now - _startTime).TotalSeconds; CardElapsed.Text = elapsed >= 60 ? $"{elapsed / 60:F0}m {elapsed % 60:F0}s" : $"{elapsed:F1}s"; CardIterations.Text = _maxIteration.ToString(); var totalTokens = _totalInputTokens + _totalOutputTokens; CardTokens.Text = totalTokens >= 1000 ? $"{totalTokens / 1000.0:F1}k" : totalTokens.ToString(); CardInputTokens.Text = _totalInputTokens >= 1000 ? $"{_totalInputTokens / 1000.0:F1}k" : _totalInputTokens.ToString(); CardOutputTokens.Text = _totalOutputTokens >= 1000 ? $"{_totalOutputTokens / 1000.0:F1}k" : _totalOutputTokens.ToString(); CardToolCalls.Text = _totalToolCalls.ToString(); } // ─── 유틸리티 ──────────────────────────────────────────────── private static string FormatMs(long ms) => ms >= 1000 ? $"{ms / 1000.0:F1}s" : $"{ms}ms"; // ─── 윈도우 이벤트 ──────────────────────────────────────────── private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { // 타이틀 바 버튼 영역 클릭 시 드래그 무시 (DragMove가 마우스를 캡처하여 MouseLeftButtonUp 차단 방지) var src = e.OriginalSource as DependencyObject; while (src != null && src != sender) { if (src is Border b && b.Name is "BtnClose" or "BtnMinimize" or "BtnClear") return; src = VisualTreeHelper.GetParent(src); } if (e.ClickCount == 1) DragMove(); } private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Hide(); private void BtnMinimize_Click(object sender, MouseButtonEventArgs e) => WindowState = WindowState.Minimized; private void BtnClear_Click(object sender, MouseButtonEventArgs e) => Reset(); private void TitleBtn_MouseEnter(object sender, MouseEventArgs e) { if (sender is Border b) b.Background = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); } private void TitleBtn_MouseLeave(object sender, MouseEventArgs e) { if (sender is Border b) b.Background = Brushes.Transparent; } private static string Truncate(string text, int maxLen) => text.Length <= maxLen ? text : text[..maxLen] + "…"; private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { if (msg == WM_NCHITTEST) { var pt = PointFromScreen(new Point( (short)(lParam.ToInt32() & 0xFFFF), (short)((lParam.ToInt32() >> 16) & 0xFFFF))); const double grip = 8; var w = ActualWidth; var h = ActualHeight; if (pt.X < grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPLEFT; } if (pt.X > w - grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPRIGHT; } if (pt.X < grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMLEFT; } if (pt.X > w - grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMRIGHT; } if (pt.X < grip) { handled = true; return (IntPtr)HTLEFT; } if (pt.X > w - grip) { handled = true; return (IntPtr)HTRIGHT; } if (pt.Y < grip) { handled = true; return (IntPtr)HTTOP; } if (pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOM; } } return IntPtr.Zero; } }