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;
}
}