using System; using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Animation; using AxCopilot.Models; using AxCopilot.Services; namespace AxCopilot.Views; public partial class ChatWindow { private UIElement CreateMessageBubbleElement(string role, string content, bool animate = true, ChatMessage? message = null) { var beforeCount = GetTranscriptElementCount(); AddMessageBubble(role, content, animate, message); var element = GetTranscriptElementAt(beforeCount) ?? throw new InvalidOperationException("메시지 버블을 생성하지 못했습니다."); RemoveTranscriptElementAt(beforeCount); return element; } private void AddMessageBubble(string role, string content, bool animate = true, ChatMessage? message = null) { var isUser = role == "user"; var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)); 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 userBubbleBg = hintBg; var assistantBubbleBg = itemBg; if (isUser) { var msgMaxWidth = GetMessageMaxWidth(); var wrapper = new StackPanel { HorizontalAlignment = HorizontalAlignment.Right, MaxWidth = msgMaxWidth * 0.85, Margin = new Thickness(60, 8, 12, 10), // 좌측 여백 넓게 → 우측에 붙음 }; var bubble = new Border { BorderBrush = borderBrush, BorderThickness = new Thickness(0), CornerRadius = new CornerRadius(18), Padding = new Thickness(16, 12, 16, 12), HorizontalAlignment = HorizontalAlignment.Right, }; // DynamicResource 방식으로 바인딩 — 테마 전환 시 기존 버블도 자동 업데이트 bubble.SetResourceReference(Border.BackgroundProperty, "HintBackground"); if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) || string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase)) { var userCodeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray; MarkdownRenderer.EnableFilePathHighlight = (System.Windows.Application.Current as App)?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true; MarkdownRenderer.EnableCodeSymbolHighlight = true; bubble.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, userCodeBgBrush); } else { MarkdownRenderer.EnableCodeSymbolHighlight = false; bubble.Child = new TextBlock { Text = content, TextAlignment = TextAlignment.Left, FontSize = 14, Foreground = primaryText, TextWrapping = TextWrapping.Wrap, LineHeight = 22, }; } wrapper.Children.Add(bubble); var userActionBar = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Opacity = 0, Margin = new Thickness(0, 2, 0, 0), }; var capturedUserContent = content; var userBtnColor = secondaryText; userActionBar.Children.Add(CreateActionButton("\uE8C8", "복사", userBtnColor, () => { try { Clipboard.SetText(capturedUserContent); } catch { } })); userActionBar.Children.Add(CreateActionButton("\uE70F", "편집", userBtnColor, () => EnterEditMode(wrapper, capturedUserContent))); var userBottomBar = new Grid { Margin = new Thickness(0, 1, 0, 0) }; userBottomBar.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); userBottomBar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); userBottomBar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); var timestamp = message?.Timestamp ?? DateTime.Now; Grid.SetColumn(userActionBar, 1); userBottomBar.Children.Add(userActionBar); var timestampText = new TextBlock { Text = timestamp.ToString("HH:mm"), FontSize = 10.5, Opacity = 0, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(8, 0, 2, 1), }; Grid.SetColumn(timestampText, 2); userBottomBar.Children.Add(timestampText); wrapper.Children.Add(userBottomBar); wrapper.MouseEnter += (_, _) => { userActionBar.BeginAnimation(OpacityProperty, new DoubleAnimation(0.7, TimeSpan.FromMilliseconds(150))); timestampText.BeginAnimation(OpacityProperty, new DoubleAnimation(0.5, TimeSpan.FromMilliseconds(150))); }; wrapper.MouseLeave += (_, _) => { var targetOpacity = ReferenceEquals(_selectedMessageActionBar, userActionBar) ? 1.0 : 0.0; userActionBar.BeginAnimation(OpacityProperty, new DoubleAnimation(targetOpacity, TimeSpan.FromMilliseconds(200))); timestampText.BeginAnimation(OpacityProperty, new DoubleAnimation(0, TimeSpan.FromMilliseconds(200))); }; wrapper.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(userActionBar, bubble); var userContent = content; wrapper.MouseRightButtonUp += (_, re) => { re.Handled = true; ShowMessageContextMenu(userContent, "user"); }; if (animate) ApplyMessageEntryAnimation(wrapper); if (message?.MsgId != null) _elementCache[$"m_{message.MsgId}"] = wrapper; AddTranscriptElement(wrapper); return; } if (message != null && IsCompactionMetaMessage(message)) { var compactCard = CreateCompactionMetaCard(message, primaryText, secondaryText, hintBg, borderBrush, accentBrush); if (animate) ApplyMessageEntryAnimation(compactCard); if (message.MsgId != null) _elementCache[$"m_{message.MsgId}"] = compactCard; AddTranscriptElement(compactCard); return; } var assistantMaxWidth = GetMessageMaxWidth(); var container = new StackPanel { HorizontalAlignment = HorizontalAlignment.Left, MaxWidth = assistantMaxWidth, Margin = new Thickness(12, 10, 60, 10), // 우측 여백 넓게 → 좌측에 붙음 }; if (animate) ApplyMessageEntryAnimation(container); var (agentName, _, _) = GetAgentIdentity(); var (iconHost, iconPixels, iconGlows, iconRotate, iconScale) = CreateMiniLauncherIconEx(4.0, "none"); var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(2, 4, 0, 0) }; header.Children.Add(iconHost); header.Children.Add(new TextBlock { Text = agentName, FontSize = 12, FontWeight = FontWeights.Medium, Foreground = secondaryText, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center, }); // 아이콘 애니메이션 적용 var canvas = iconHost.Children.OfType().FirstOrDefault(); if (canvas != null) { var animState = new ChatIconAnimState { Host = iconHost, Canvas = canvas, Pixels = iconPixels, Glows = iconGlows, Rotate = iconRotate, Scale = iconScale, IsRandomMode = _settings.Settings.Launcher.EnableChatIconRandomAnimation, }; StartChatIconAnimation(animState); } var contentCard = new Border { Background = Brushes.Transparent, BorderThickness = new Thickness(0), CornerRadius = new CornerRadius(0), Padding = new Thickness(2, 4, 2, 4), }; var contentStack = new StackPanel(); var app = System.Windows.Application.Current as App; MarkdownRenderer.EnableFilePathHighlight = app?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true; MarkdownRenderer.EnableCodeSymbolHighlight = string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) || string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase); var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray; if (IsBranchContextMessage(content)) { var branchRun = GetAgentRunStateById(message?.MetaRunId) ?? GetLatestBranchContextRun(); var branchFiles = GetBranchContextFilePaths(message?.MetaRunId ?? branchRun?.RunId, 3); var branchCard = new Border { Background = hintBg, BorderBrush = borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(14), Padding = new Thickness(12, 10, 12, 10), Margin = new Thickness(0, 0, 0, 6), }; var branchStack = new StackPanel(); branchStack.Children.Add(new TextBlock { Text = "분기 컨텍스트", FontSize = 11, FontWeight = FontWeights.SemiBold, Foreground = primaryText, Margin = new Thickness(0, 0, 0, 6), }); branchStack.Children.Add(MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, codeBgBrush)); if (branchFiles.Count > 0) { var filesWrap = new WrapPanel { Margin = new Thickness(0, 8, 0, 0) }; foreach (var path in branchFiles) { var fileButton = new Button { Background = itemBg, BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(8, 3, 8, 3), Margin = new Thickness(0, 0, 6, 6), Cursor = Cursors.Hand, ToolTip = path, Content = new TextBlock { Text = System.IO.Path.GetFileName(path), FontSize = 10, FontWeight = FontWeights.SemiBold, Foreground = primaryText, }, }; var capturedPath = path; fileButton.Click += (_, _) => OpenRunFilePath(capturedPath); filesWrap.Children.Add(fileButton); } branchStack.Children.Add(filesWrap); } if (branchRun != null) { var actionsWrap = new WrapPanel { Margin = new Thickness(0, 8, 0, 0) }; var followUpButton = new Button { Background = itemBg, BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(8, 3, 8, 3), Margin = new Thickness(0, 0, 6, 6), Cursor = Cursors.Hand, Content = new TextBlock { Text = "후속 작업 큐에 넣기", FontSize = 10, FontWeight = FontWeights.SemiBold, Foreground = primaryText, }, }; var capturedBranchRun = branchRun; followUpButton.Click += (_, _) => EnqueueFollowUpFromRun(capturedBranchRun); actionsWrap.Children.Add(followUpButton); var timelineButton = new Button { Background = itemBg, BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(8, 3, 8, 3), Margin = new Thickness(0, 0, 6, 6), Cursor = Cursors.Hand, Content = new TextBlock { Text = "관련 로그로 이동", FontSize = 10, FontWeight = FontWeights.SemiBold, Foreground = primaryText, }, }; timelineButton.Click += (_, _) => ScrollToRunInTimeline(capturedBranchRun.RunId); actionsWrap.Children.Add(timelineButton); branchStack.Children.Add(actionsWrap); } branchCard.Child = branchStack; contentStack.Children.Add(branchCard); } else { contentStack.Children.Add(MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, codeBgBrush)); } contentCard.Child = contentStack; container.Children.Add(contentCard); // 에이전트 이름 푸터 (메시지 본문 아래) container.Children.Add(header); // 어시스턴트 메시지에 파일 경로가 포함되어 있으면 프리뷰/열기 퀵 액션 추가 var outputFilePath = ExtractOutputFilePathFromContent(content); if (!string.IsNullOrEmpty(outputFilePath) && System.IO.File.Exists(outputFilePath)) { var quickActions = BuildFileQuickActions(outputFilePath); quickActions.Margin = new Thickness(2, 4, 0, 2); container.Children.Add(quickActions); } var actionBar = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(2, 2, 0, 0), Opacity = 0, }; var btnColor = secondaryText; var capturedContent = content; actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () => { try { Clipboard.SetText(capturedContent); } catch { } })); actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync())); actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput())); AddLinkedFeedbackButtons(actionBar, btnColor, message); var aiTimestamp = message?.Timestamp ?? DateTime.Now; actionBar.Children.Add(new TextBlock { Text = aiTimestamp.ToString("HH:mm"), FontSize = 10.5, Opacity = 0.52, Foreground = btnColor, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(4, 0, 0, 1), }); container.Children.Add(actionBar); var assistantMeta = CreateAssistantMessageMetaText(message); if (assistantMeta != null) container.Children.Add(assistantMeta); container.MouseEnter += (_, _) => { actionBar.BeginAnimation(OpacityProperty, new DoubleAnimation(1, TimeSpan.FromMilliseconds(150))); }; container.MouseLeave += (_, _) => { var targetOpacity = ReferenceEquals(_selectedMessageActionBar, actionBar) ? 1.0 : 0.0; actionBar.BeginAnimation(OpacityProperty, new DoubleAnimation(targetOpacity, TimeSpan.FromMilliseconds(200))); }; container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, contentCard); var aiContent = content; container.MouseRightButtonUp += (_, re) => { re.Handled = true; ShowMessageContextMenu(aiContent, "assistant"); }; if (message?.MsgId != null) _elementCache[$"m_{message.MsgId}"] = container; AddTranscriptElement(container); } /// /// 어시스턴트 메시지 텍스트에서 출력 파일 경로(절대 경로)를 추출합니다. /// "완료: C:\...\file.ext" 패턴을 우선 찾고, 없으면 일반 절대 경로를 검색합니다. /// private static string? ExtractOutputFilePathFromContent(string content) { if (string.IsNullOrWhiteSpace(content)) return null; // 패턴 1: "완료: C:\path\file.ext" 또는 "완료: E:\path\file.ext" var completionMatch = System.Text.RegularExpressions.Regex.Match( content, @"완료:\s*([A-Za-z]:\\[^\s\n""',;)]+\.(?:docx|xlsx|pptx|html|htm|pdf|csv|md|txt))"); if (completionMatch.Success) return completionMatch.Groups[1].Value.TrimEnd('.'); // 패턴 2: 임의의 절대 경로 (알려진 문서 확장자) var absMatch = System.Text.RegularExpressions.Regex.Match( content, @"([A-Za-z]:\\[^\s\n""',;)]+\.(?:docx|xlsx|pptx|html|htm|pdf|csv|md|txt))"); if (absMatch.Success) return absMatch.Groups[1].Value.TrimEnd('.'); return null; } }