Files
AX-Copilot-Codex/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml.cs

929 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// 에이전트 워크플로우 분석기.
/// 에이전트 실행 과정을 세로 타임라인 형태로 실시간 시각화합니다.
/// 개발자 모드 → 워크플로우 시각화 설정이 켜져있을 때 자동으로 열립니다.
/// </summary>
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<WaterfallEntry> _waterfallEntries = new();
private readonly Dictionary<string, long> _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;
};
}
/// <summary>에이전트 이벤트를 수신하여 타임라인에 추가합니다.</summary>
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();
}
/// <summary>타임라인을 초기화합니다.</summary>
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 = "대기 중...";
}
/// <summary>타임라인 탭을 활성화합니다.</summary>
public void SwitchToTimelineTab()
{
_activeTab = "timeline";
UpdateTabVisuals();
}
/// <summary>병목 분석 탭을 활성화합니다. 외부(ChatWindow)에서 호출합니다.</summary>
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();
}
/// <summary>워터폴 차트: LLM 호출과 도구 실행을 시간 순서로 표시. 병목 구간은 빨간색.</summary>
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);
}
}
}
/// <summary>도구별 누적 소요시간 수평 바 차트.</summary>
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);
}
}
/// <summary>반복별 토큰 추세 꺾은선 그래프.</summary>
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<Point> 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;
}
}