using System; using System.Windows; using System.Windows.Controls; using System.Windows.Documents; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Animation; using AxCopilot.Services; using AxCopilot.Services.Agent; namespace AxCopilot.Views; public partial class ChatWindow { /// V2: 단일 에이전트 이벤트를 렌더링 (ToolCall/ToolResult 병합되지 않은 경우) private UIElement CreateV2AgentEventElement(AgentEvent agentEvent) { return agentEvent.Type switch { AgentEventType.Thinking => CreateV2ThinkingBlock(agentEvent), AgentEventType.ToolCall => CreateV2ToolCallOnlyCard(agentEvent), AgentEventType.ToolResult => CreateV2ToolResultOnlyCard(agentEvent), AgentEventType.Complete => CreateV2CompleteBanner(agentEvent), AgentEventType.Error => CreateV2ErrorBanner(agentEvent), _ => CreateV2GenericEventPill(agentEvent), }; } /// V2: ToolCall + ToolResult 쌍을 병합한 실행 카드 private UIElement CreateV2ToolExecutionCard(AgentEvent toolCall, AgentEvent toolResult) { var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; var hintBg = TryFindResource("HintBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; var msgMaxWidth = GetMessageMaxWidth(); var (icon, iconColor) = GetV2ToolIcon(toolCall.ToolName); var elapsed = NormalizeProgressElapsedMs(toolResult.ElapsedMs); var elapsedText = elapsed > 0 ? $"{elapsed / 1000.0:F1}s" : ""; var isSuccess = toolResult.Success; // 외부 컨테이너 — 왼쪽 세로선 + 카드 var outerGrid = new Grid { HorizontalAlignment = HorizontalAlignment.Center, Width = msgMaxWidth, MaxWidth = msgMaxWidth, Margin = new Thickness(0, 2, 0, 2), }; outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // 왼쪽 세로선 var lineColor = isSuccess ? new SolidColorBrush(Color.FromArgb(0x60, 0x66, 0xBB, 0x6A)) : new SolidColorBrush(Color.FromArgb(0x60, 0xEF, 0x53, 0x50)); var verticalLine = new Border { Width = 2, Background = lineColor, CornerRadius = new CornerRadius(1), Margin = new Thickness(12, 0, 8, 0), VerticalAlignment = VerticalAlignment.Stretch, }; Grid.SetColumn(verticalLine, 0); outerGrid.Children.Add(verticalLine); // 메인 카드 var card = new Border { Background = hintBg, BorderBrush = borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(10), Padding = new Thickness(12, 8, 12, 8), Cursor = Cursors.Hand, }; Grid.SetColumn(card, 1); var cardStack = new StackPanel(); // 헤더: 아이콘 + 도구명 + 파일경로 + 소요시간 var headerGrid = new Grid(); headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 아이콘 headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 도구명 headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // 파일경로 headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 상태+시간 var statusIcon = isSuccess ? "\uE73E" : "\uE711"; // 체크 or X var statusColor = isSuccess ? new SolidColorBrush(Color.FromRgb(0x66, 0xBB, 0x6A)) : new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50)); var iconTb = new TextBlock { Text = icon, FontSize = 13, Foreground = iconColor, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), }; Grid.SetColumn(iconTb, 0); headerGrid.Children.Add(iconTb); var toolNameTb = new TextBlock { Text = GetV2ToolDisplayName(toolCall.ToolName), FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = primaryText, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), }; Grid.SetColumn(toolNameTb, 1); headerGrid.Children.Add(toolNameTb); // 파일 경로 (있으면) var filePath = toolCall.FilePath ?? toolResult.FilePath; if (!string.IsNullOrWhiteSpace(filePath)) { var pathTb = new TextBlock { Text = TruncateFilePath(filePath, 60), FontSize = 10.5, Foreground = secondaryText, Opacity = 0.8, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, ToolTip = filePath, }; Grid.SetColumn(pathTb, 2); headerGrid.Children.Add(pathTb); } // 상태 아이콘 + 소요시간 var statusPanel = new StackPanel { Orientation = Orientation.Horizontal }; statusPanel.Children.Add(new TextBlock { Text = statusIcon, FontFamily = s_segoeIconFont, FontSize = 11, Foreground = statusColor, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0), }); if (!string.IsNullOrEmpty(elapsedText)) { statusPanel.Children.Add(new TextBlock { Text = elapsedText, FontSize = 10, Foreground = secondaryText, Opacity = 0.7, VerticalAlignment = VerticalAlignment.Center, }); } Grid.SetColumn(statusPanel, 3); headerGrid.Children.Add(statusPanel); cardStack.Children.Add(headerGrid); // Summary 텍스트 (있으면) var summary = toolCall.Summary ?? toolResult.Summary; if (!string.IsNullOrWhiteSpace(summary)) { var summaryTb = new TextBlock { Text = summary, FontSize = 10.5, Foreground = secondaryText, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 4, 0, 0), MaxHeight = 40, TextTrimming = TextTrimming.CharacterEllipsis, }; cardStack.Children.Add(summaryTb); } // 접힘/펼침 상세 영역 var detailBorder = new Border { Visibility = Visibility.Collapsed, Margin = new Thickness(0, 6, 0, 0), }; var detailStack = new StackPanel(); var codeBg = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray; // 도구 입력 파라미터 (ToolInput이 있으면 표시) var toolInput = toolCall.ToolInput; if (!string.IsNullOrWhiteSpace(toolInput)) { detailStack.Children.Add(new TextBlock { Text = "입력", FontSize = 10, FontWeight = FontWeights.SemiBold, Foreground = secondaryText, Opacity = 0.7, Margin = new Thickness(0, 0, 0, 3), }); detailStack.Children.Add(MarkdownRenderer.Render( $"```json\n{TruncateForDisplay(toolInput, 1200)}\n```", primaryText, secondaryText, accentBrush, codeBg)); } // 도구 결과 상세 내용 var resultSummary = toolResult.Summary; if (!string.IsNullOrWhiteSpace(resultSummary)) { // 입력이 이미 있으면 "결과" 라벨 추가 if (detailStack.Children.Count > 0) { detailStack.Children.Add(new TextBlock { Text = "결과", FontSize = 10, FontWeight = FontWeights.SemiBold, Foreground = secondaryText, Opacity = 0.7, Margin = new Thickness(0, 6, 0, 3), }); } detailStack.Children.Add(MarkdownRenderer.Render( $"```\n{TruncateForDisplay(resultSummary, 2000)}\n```", primaryText, secondaryText, accentBrush, codeBg)); } // 토큰 메타 정보 var inputTokens = toolResult.InputTokens; var outputTokens = toolResult.OutputTokens; if (inputTokens > 0 || outputTokens > 0) { var metaParts = new System.Collections.Generic.List(); if (inputTokens > 0) metaParts.Add($"입력: {inputTokens:N0} 토큰"); if (outputTokens > 0) metaParts.Add($"출력: {outputTokens:N0} 토큰"); detailStack.Children.Add(new TextBlock { Text = string.Join(" · ", metaParts), FontSize = 9.5, Foreground = secondaryText, Opacity = 0.6, Margin = new Thickness(0, 4, 0, 0), }); } detailBorder.Child = detailStack; // 상세 내용이 없으면 화살표도 숨김 var hasDetail = detailStack.Children.Count > 0; cardStack.Children.Add(detailBorder); // 펼치기 토글 화살표 (상세 내용이 있을 때만 표시) var arrowTb = new TextBlock { Text = "\uE76C", // 아래 화살표 FontFamily = s_segoeIconFont, FontSize = 9, Foreground = secondaryText, Opacity = 0.7, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 3, 0, 0), Cursor = Cursors.Hand, Visibility = hasDetail ? Visibility.Visible : Visibility.Collapsed, }; cardStack.Children.Add(arrowTb); card.Child = cardStack; outerGrid.Children.Add(card); // 클릭 → 접힘/펼침 토글 card.MouseLeftButtonUp += (_, e) => { e.Handled = true; var isExpanded = detailBorder.Visibility == Visibility.Visible; detailBorder.Visibility = isExpanded ? Visibility.Collapsed : Visibility.Visible; arrowTb.Text = isExpanded ? "\uE76C" : "\uE76B"; // 아래↔위 }; // 호버 효과 (부드러운 전환) card.MouseEnter += (_, _) => { card.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(120))); card.Background = TryFindResource("ItemHoverBackground") as Brush ?? hintBg; }; card.MouseLeave += (_, _) => { card.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0.92, TimeSpan.FromMilliseconds(200))); card.Background = hintBg; }; card.Opacity = 0.92; return outerGrid; } /// V2: Thinking 블록 — 점선 테두리, 이탤릭 텍스트 private UIElement CreateV2ThinkingBlock(AgentEvent agentEvent) { var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var msgMaxWidth = GetMessageMaxWidth(); var summary = agentEvent.Summary; if (string.IsNullOrWhiteSpace(summary)) return new Border { Width = 0, Height = 0 }; // 빈 thinking은 숨김 // agent_wait / context_compaction 같은 운영 이벤트 if (string.Equals(agentEvent.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase) || string.Equals(agentEvent.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase)) { return CreateV2GenericEventPill(agentEvent); } var outerGrid = new Grid { HorizontalAlignment = HorizontalAlignment.Center, Width = msgMaxWidth, MaxWidth = msgMaxWidth, Margin = new Thickness(0, 2, 0, 2), }; outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // 왼쪽 세로선 (사고 과정 = 파란색 점선 느낌) var thinkLine = new Border { Width = 2, Background = new SolidColorBrush(Color.FromArgb(0x60, 0x59, 0xA5, 0xF5)), CornerRadius = new CornerRadius(1), Margin = new Thickness(12, 0, 8, 0), }; Grid.SetColumn(thinkLine, 0); outerGrid.Children.Add(thinkLine); var thinkCard = new Border { Background = new SolidColorBrush(Color.FromArgb(0x1C, 0x59, 0xA5, 0xF5)), BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0x59, 0xA5, 0xF5)), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8), Padding = new Thickness(10, 6, 10, 6), }; Grid.SetColumn(thinkCard, 1); var thinkStack = new StackPanel { Orientation = Orientation.Horizontal }; thinkStack.Children.Add(new TextBlock { Text = "\uE915", // 전구 아이콘 FontFamily = s_segoeIconFont, FontSize = 11, Foreground = new SolidColorBrush(Color.FromRgb(0x59, 0xA5, 0xF5)), VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 1, 6, 0), }); thinkStack.Children.Add(new TextBlock { Text = summary.Length > 200 ? summary[..200] + "..." : summary, FontSize = 11, FontStyle = FontStyles.Italic, Foreground = secondaryText, Opacity = 0.88, TextWrapping = TextWrapping.Wrap, MaxWidth = msgMaxWidth - 60, }); thinkCard.Child = thinkStack; outerGrid.Children.Add(thinkCard); return outerGrid; } /// V2: ToolCall만 있고 ToolResult가 없는 경우 private UIElement CreateV2ToolCallOnlyCard(AgentEvent agentEvent) { var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; var hintBg = TryFindResource("HintBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); var msgMaxWidth = GetMessageMaxWidth(); var (icon, iconColor) = GetV2ToolIcon(agentEvent.ToolName); var outerGrid = new Grid { HorizontalAlignment = HorizontalAlignment.Center, Width = msgMaxWidth, MaxWidth = msgMaxWidth, Margin = new Thickness(0, 2, 0, 2), }; outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); var line = new Border { Width = 2, Background = new SolidColorBrush(Color.FromArgb(0x40, 0x59, 0xA5, 0xF5)), CornerRadius = new CornerRadius(1), Margin = new Thickness(12, 0, 8, 0), }; Grid.SetColumn(line, 0); outerGrid.Children.Add(line); var pill = new Border { Background = hintBg, BorderBrush = borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8), Padding = new Thickness(10, 6, 10, 6), }; Grid.SetColumn(pill, 1); var sp = new StackPanel { Orientation = Orientation.Horizontal }; sp.Children.Add(new TextBlock { Text = icon, FontSize = 12, Foreground = iconColor, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), }); sp.Children.Add(new TextBlock { Text = GetV2ToolDisplayName(agentEvent.ToolName), FontSize = 11, FontWeight = FontWeights.SemiBold, Foreground = primaryText, VerticalAlignment = VerticalAlignment.Center, }); if (!string.IsNullOrWhiteSpace(agentEvent.FilePath)) { sp.Children.Add(new TextBlock { Text = TruncateFilePath(agentEvent.FilePath, 50), FontSize = 10, Foreground = secondaryText, Opacity = 0.7, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(8, 0, 0, 0), }); } pill.Child = sp; outerGrid.Children.Add(pill); return outerGrid; } /// V2: ToolResult만 있고 ToolCall이 없는 경우 private UIElement CreateV2ToolResultOnlyCard(AgentEvent agentEvent) { // ToolCall과 유사하지만 결과 표시 return CreateV2ToolCallOnlyCard(agentEvent); } /// V2: 작업 완료 배너 private UIElement CreateV2CompleteBanner(AgentEvent agentEvent) { var msgMaxWidth = GetMessageMaxWidth(); var elapsed = NormalizeProgressElapsedMs(agentEvent.ElapsedMs); var elapsedText = elapsed > 0 ? $" · {elapsed / 1000.0:F1}s" : ""; var tokenInfo = ""; if (agentEvent.InputTokens > 0 || agentEvent.OutputTokens > 0) { var parts = new System.Collections.Generic.List(); if (agentEvent.InputTokens > 0) parts.Add($"입력 {agentEvent.InputTokens:N0}"); if (agentEvent.OutputTokens > 0) parts.Add($"출력 {agentEvent.OutputTokens:N0}"); tokenInfo = $" · {string.Join("/", parts)} 토큰"; } var banner = new Border { Background = new SolidColorBrush(Color.FromArgb(0x24, 0x66, 0xBB, 0x6A)), BorderBrush = new SolidColorBrush(Color.FromArgb(0x50, 0x66, 0xBB, 0x6A)), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8), Padding = new Thickness(14, 8, 14, 8), Margin = new Thickness(0, 6, 0, 4), HorizontalAlignment = HorizontalAlignment.Center, MaxWidth = msgMaxWidth, }; var sp = new StackPanel { Orientation = Orientation.Horizontal }; sp.Children.Add(new TextBlock { Text = "\uE73E", // 체크마크 FontFamily = s_segoeIconFont, FontSize = 12, Foreground = new SolidColorBrush(Color.FromRgb(0x66, 0xBB, 0x6A)), VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), }); sp.Children.Add(new TextBlock { Text = $"작업 완료{elapsedText}{tokenInfo}", FontSize = 11, Foreground = new SolidColorBrush(Color.FromRgb(0x66, 0xBB, 0x6A)), VerticalAlignment = VerticalAlignment.Center, }); banner.Child = sp; return banner; } /// V2: 에러 배너 private UIElement CreateV2ErrorBanner(AgentEvent agentEvent) { var msgMaxWidth = GetMessageMaxWidth(); var banner = new Border { Background = new SolidColorBrush(Color.FromArgb(0x24, 0xEF, 0x53, 0x50)), BorderBrush = new SolidColorBrush(Color.FromArgb(0x50, 0xEF, 0x53, 0x50)), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8), Padding = new Thickness(14, 8, 14, 8), Margin = new Thickness(0, 6, 0, 4), HorizontalAlignment = HorizontalAlignment.Center, MaxWidth = msgMaxWidth, }; var sp = new StackPanel { Orientation = Orientation.Horizontal }; sp.Children.Add(new TextBlock { Text = "\uE711", // X FontFamily = s_segoeIconFont, FontSize = 12, Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50)), VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), }); sp.Children.Add(new TextBlock { Text = $"오류 발생: {agentEvent.Summary ?? "알 수 없는 오류"}", FontSize = 11, Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50)), TextWrapping = TextWrapping.Wrap, VerticalAlignment = VerticalAlignment.Center, }); banner.Child = sp; return banner; } /// V2: 일반 이벤트 pill (분류 안 되는 이벤트) private UIElement CreateV2GenericEventPill(AgentEvent agentEvent) { var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var hintBg = TryFindResource("HintBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; var msgMaxWidth = GetMessageMaxWidth(); var displayText = agentEvent.Summary ?? agentEvent.ToolName ?? agentEvent.Type.ToString(); var pill = new Border { Background = hintBg, BorderBrush = borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8), Padding = new Thickness(10, 4, 10, 4), Margin = new Thickness(0, 2, 0, 2), HorizontalAlignment = HorizontalAlignment.Center, MaxWidth = msgMaxWidth, }; pill.Child = new TextBlock { Text = displayText, FontSize = 10.5, Foreground = secondaryText, TextTrimming = TextTrimming.CharacterEllipsis, }; return pill; } // ─── V2 헬퍼 ──────────────────────────────────────────────────────── private static (string Icon, Brush Color) GetV2ToolIcon(string? toolName) { if (string.IsNullOrWhiteSpace(toolName)) return ("\uE90F", Brushes.Gray); // 기어 var lower = toolName.ToLowerInvariant(); if (lower.Contains("read") || lower.Contains("document_read")) return ("\uE8E5", new SolidColorBrush(Color.FromRgb(0x42, 0xA5, 0xF5))); // 파일 읽기 파랑 if (lower.Contains("write") || lower.Contains("edit") || lower.Contains("create")) return ("\uE70F", new SolidColorBrush(Color.FromRgb(0x66, 0xBB, 0x6A))); // 편집 초록 if (lower.Contains("search") || lower.Contains("web")) return ("\uE721", new SolidColorBrush(Color.FromRgb(0xFF, 0xB7, 0x4D))); // 검색 주황 if (lower.Contains("bash") || lower.Contains("script") || lower.Contains("run")) return ("\uE756", new SolidColorBrush(Color.FromRgb(0xAB, 0x47, 0xBC))); // 실행 보라 if (lower.Contains("delete") || lower.Contains("remove")) return ("\uE74D", new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50))); // 삭제 빨강 return ("\uE90F", new SolidColorBrush(Color.FromRgb(0x78, 0x90, 0x9C))); // 기타 회색 } private static string GetV2ToolDisplayName(string? toolName) { if (string.IsNullOrWhiteSpace(toolName)) return "Tool"; return toolName switch { "file_read" => "Read file", "document_read" => "Read document", "file_write" => "Write file", "file_edit" => "Edit file", "web_search" => "Web search", "bash" => "Run command", "script_run" => "Run script", "html_create" => "Create HTML", "docx_create" => "Create DOCX", "xlsx_create" or "excel_create" => "Create Excel", "pptx_create" => "Create PPTX", "csv_create" => "Create CSV", "markdown_create" or "md_create" => "Create Markdown", "context_compaction" => "Context compaction", "agent_wait" => "Waiting", _ => toolName, }; } private static string TruncateFilePath(string path, int maxLength) { if (string.IsNullOrEmpty(path) || path.Length <= maxLength) return path; var fileName = System.IO.Path.GetFileName(path); if (fileName.Length >= maxLength - 3) return "..." + fileName[^(maxLength - 3)..]; var remaining = maxLength - fileName.Length - 4; // ".../" 포함 if (remaining <= 0) return ".../" + fileName; return path[..remaining] + ".../" + fileName; } /// 긴 텍스트를 지정 길이로 잘라서 표시용으로 반환합니다. private static string TruncateForDisplay(string text, int maxLength) { if (string.IsNullOrEmpty(text) || text.Length <= maxLength) return text; return text[..maxLength] + "\n… (truncated)"; } }