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 outline, string filled, string tooltip, Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "", Action? resetSibling = null, Action? registerReset = null) { var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var isActive = message?.Feedback == feedbackType; var icon = new TextBlock { Text = isActive ? filled : outline, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 12, Foreground = isActive ? activeColor : normalColor, VerticalAlignment = VerticalAlignment.Center, RenderTransformOrigin = new Point(0.5, 0.5), RenderTransform = new ScaleTransform(1, 1) }; var btn = new Button { Content = icon, Background = Brushes.Transparent, BorderThickness = new Thickness(0), Cursor = Cursors.Hand, Padding = new Thickness(6, 4, 6, 4), Margin = new Thickness(0, 0, 4, 0), ToolTip = tooltip }; registerReset?.Invoke(() => { isActive = false; icon.Text = outline; icon.Foreground = normalColor; }); btn.MouseEnter += (_, _) => { if (!isActive) icon.Foreground = hoverBrush; }; btn.MouseLeave += (_, _) => { if (!isActive) icon.Foreground = normalColor; }; btn.Click += (_, _) => { isActive = !isActive; icon.Text = isActive ? filled : outline; icon.Foreground = isActive ? activeColor : normalColor; if (isActive) { resetSibling?.Invoke(); } if (message != null) { try { var feedback = isActive ? feedbackType : null; 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 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) { Action? resetLikeAction = null; Action? resetDislikeAction = null; var likeBtn = CreateFeedbackButton("\uE8E1", "\uEB51", "좋아요", btnColor, new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)), message, "like", resetSibling: () => resetDislikeAction?.Invoke(), registerReset: reset => resetLikeAction = reset); var dislikeBtn = CreateFeedbackButton("\uE8E0", "\uEB50", "싫어요", btnColor, new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E)), message, "dislike", resetSibling: () => resetLikeAction?.Invoke(), registerReset: reset => resetDislikeAction = reset); 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(); } }