using System; using System.Collections.Generic; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Animation; using AxCopilot.Models; namespace AxCopilot.Views; public partial class ChatWindow { /// 좋아요/싫어요 피드백 버튼을 생성합니다. private Button CreateFeedbackButton( string iconGlyph, string tooltip, Brush normalColor, Brush activeColor, Func isActive, Action toggle) { var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var activeBackground = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(18, 255, 255, 255)); var icon = new TextBlock { Text = iconGlyph, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 12, Foreground = normalColor, VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center, RenderTransformOrigin = new Point(0.5, 0.5), RenderTransform = new ScaleTransform(1, 1) }; var chip = new Border { Background = Brushes.Transparent, BorderBrush = Brushes.Transparent, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8), Padding = new Thickness(6, 4, 6, 4), Margin = new Thickness(0, 0, 4, 0), Child = icon }; var btn = new Button { Content = chip, Background = Brushes.Transparent, BorderThickness = new Thickness(0), Cursor = Cursors.Hand, Padding = new Thickness(0), ToolTip = tooltip }; void RefreshVisual() { var active = isActive(); icon.Foreground = active ? activeColor : normalColor; chip.Background = active ? activeBackground : Brushes.Transparent; chip.BorderBrush = active ? activeColor : Brushes.Transparent; } RefreshVisual(); btn.MouseEnter += (_, _) => { if (!isActive()) icon.Foreground = hoverBrush; }; btn.MouseLeave += (_, _) => { if (!isActive()) icon.Foreground = normalColor; }; btn.Click += (_, _) => { toggle(); RefreshVisual(); var scale = (ScaleTransform)icon.RenderTransform; var bounce = new DoubleAnimation(1.3, 1.0, TimeSpan.FromMilliseconds(250)) { EasingFunction = new ElasticEase { EasingMode = EasingMode.EaseOut, Oscillations = 1, Springiness = 5 } }; scale.BeginAnimation(ScaleTransform.ScaleXProperty, bounce); scale.BeginAnimation(ScaleTransform.ScaleYProperty, bounce); }; return btn; } /// 좋아요/싫어요 버튼을 상호배타 토글로 추가합니다. private void AddLinkedFeedbackButtons(StackPanel actionBar, Brush btnColor, ChatMessage? message) { string? currentFeedback = message?.Feedback; void PersistFeedback() { if (message == null) return; try { var feedback = string.IsNullOrWhiteSpace(currentFeedback) ? null : currentFeedback; var session = ChatSession; if (session != null) { lock (_convLock) { session.UpdateMessageFeedback(_activeTab, message, feedback, _storage); _currentConversation = session.CurrentConversation; } } else { message.Feedback = feedback; ChatConversation? conv; lock (_convLock) { conv = _currentConversation; } if (conv != null) _storage.Save(conv); } } catch { } } var likeBtn = CreateFeedbackButton( "\uE8E1", "좋아요", btnColor, new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)), () => string.Equals(currentFeedback, "like", StringComparison.OrdinalIgnoreCase), () => { currentFeedback = string.Equals(currentFeedback, "like", StringComparison.OrdinalIgnoreCase) ? null : "like"; PersistFeedback(); }); var dislikeBtn = CreateFeedbackButton( "\uE8E0", "싫어요", btnColor, new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E)), () => string.Equals(currentFeedback, "dislike", StringComparison.OrdinalIgnoreCase), () => { currentFeedback = string.Equals(currentFeedback, "dislike", StringComparison.OrdinalIgnoreCase) ? null : "dislike"; PersistFeedback(); }); actionBar.Children.Add(likeBtn); actionBar.Children.Add(dislikeBtn); } private TextBlock? CreateAssistantMessageMetaText(ChatMessage? message) { if (message == null) { return null; } var parts = new List(); if (message.ResponseElapsedMs is > 0) { var elapsedMs = message.ResponseElapsedMs.Value; parts.Add(elapsedMs < 1000 ? $"{elapsedMs}ms" : $"{(elapsedMs / 1000.0):0.0}s"); } if (message.PromptTokens > 0 || message.CompletionTokens > 0) { var totalTokens = message.PromptTokens + message.CompletionTokens; parts.Add($"{FormatTokenCount(totalTokens)} tokens"); } if (parts.Count == 0) { return null; } return new TextBlock { Text = string.Join(" • ", parts), FontSize = 9.75, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(4, 2, 0, 0), Opacity = 0.72, }; } private static void ApplyMessageEntryAnimation(FrameworkElement element) { element.Opacity = 0; element.RenderTransform = new TranslateTransform(0, 16); element.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(350)) { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }); ((TranslateTransform)element.RenderTransform).BeginAnimation( TranslateTransform.YProperty, new DoubleAnimation(16, 0, TimeSpan.FromMilliseconds(400)) { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }); } private bool _isEditing; private void EnterEditMode(StackPanel wrapper, string originalText) { if (_isStreaming || _isEditing) { return; } _isEditing = true; var idx = MessagePanel.Children.IndexOf(wrapper); if (idx < 0) { _isEditing = false; return; } var editPanel = new StackPanel { HorizontalAlignment = HorizontalAlignment.Right, MaxWidth = 540, Margin = wrapper.Margin, }; var editBox = new TextBox { Text = originalText, FontSize = 13.5, Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, Background = TryFindResource("ItemBackground") as Brush ?? Brushes.DarkGray, CaretBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue, BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue, BorderThickness = new Thickness(1.5), Padding = new Thickness(14, 10, 14, 10), TextWrapping = TextWrapping.Wrap, AcceptsReturn = false, MaxHeight = 200, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, }; var editBorder = new Border { CornerRadius = new CornerRadius(14), Child = editBox, ClipToBounds = true, }; editPanel.Children.Add(editBorder); var btnBar = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 6, 0, 0), }; var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var cancelBtn = new Button { Content = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryBrush }, Background = Brushes.Transparent, BorderThickness = new Thickness(0), Cursor = Cursors.Hand, Padding = new Thickness(12, 5, 12, 5), Margin = new Thickness(0, 0, 6, 0), }; cancelBtn.Click += (_, _) => { _isEditing = false; if (idx >= 0 && idx < MessagePanel.Children.Count) { MessagePanel.Children[idx] = wrapper; } }; btnBar.Children.Add(cancelBtn); var sendBtn = new Button { Cursor = Cursors.Hand, Padding = new Thickness(0), }; sendBtn.Template = (ControlTemplate)System.Windows.Markup.XamlReader.Parse( "" + "" + "" + ""); sendBtn.Click += (_, _) => { var newText = editBox.Text.Trim(); if (!string.IsNullOrEmpty(newText)) { _ = SubmitEditAsync(idx, newText); } }; btnBar.Children.Add(sendBtn); editPanel.Children.Add(btnBar); MessagePanel.Children[idx] = editPanel; editBox.KeyDown += (_, ke) => { if (ke.Key == Key.Enter && Keyboard.Modifiers == ModifierKeys.None) { ke.Handled = true; var newText = editBox.Text.Trim(); if (!string.IsNullOrEmpty(newText)) { _ = SubmitEditAsync(idx, newText); } } if (ke.Key == Key.Escape) { ke.Handled = true; _isEditing = false; if (idx >= 0 && idx < MessagePanel.Children.Count) { MessagePanel.Children[idx] = wrapper; } } }; editBox.Focus(); editBox.SelectAll(); } private async Task SubmitEditAsync(int bubbleIndex, string newText) { _isEditing = false; if (_isStreaming) { return; } ChatConversation conv; lock (_convLock) { if (_currentConversation == null) { return; } conv = _currentConversation; } var userMsgIdx = -1; var uiIdx = 0; lock (_convLock) { for (var i = 0; i < conv.Messages.Count; i++) { if (conv.Messages[i].Role == "system") { continue; } if (uiIdx == bubbleIndex) { userMsgIdx = i; break; } uiIdx++; } } if (userMsgIdx < 0) { return; } lock (_convLock) { var session = ChatSession; if (session != null) { session.UpdateUserMessageAndTrim(_activeTab, userMsgIdx, newText, _storage); _currentConversation = session.CurrentConversation; conv = _currentConversation!; } else { conv.Messages[userMsgIdx].Content = newText; while (conv.Messages.Count > userMsgIdx + 1) { conv.Messages.RemoveAt(conv.Messages.Count - 1); } } } RenderMessages(preserveViewport: true); AutoScrollIfNeeded(); await SendRegenerateAsync(conv); try { if (ChatSession == null) { _storage.Save(conv); } } catch (Exception ex) { Services.LogService.Debug($"편집 저장 실패: {ex.Message}"); } RefreshConversationList(); } }