929 lines
36 KiB
C#
929 lines
36 KiB
C#
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;
|
||
}
|
||
}
|