diff --git a/src/AxCopilot/Views/ChatWindow.MessageEdit.cs b/src/AxCopilot/Views/ChatWindow.MessageEdit.cs new file mode 100644 index 0000000..80e69f9 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.MessageEdit.cs @@ -0,0 +1,201 @@ +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 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; + + // wrapper 위치(인덱스) 기억 + var idx = MessagePanel.Children.IndexOf(wrapper); + if (idx < 0) { _isEditing = false; return; } + + // 편집 UI 생성 + var editPanel = new StackPanel + { + HorizontalAlignment = HorizontalAlignment.Right, + MaxWidth = 540, + Margin = wrapper.Margin, + }; + + var editBox = new TextBox + { + Text = originalText, + FontSize = 13.5, + Foreground = ThemeResourceHelper.Primary(this), + Background = ThemeResourceHelper.ItemBg(this), + CaretBrush = ThemeResourceHelper.Accent(this), + BorderBrush = ThemeResourceHelper.Accent(this), + 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 = ThemeResourceHelper.Accent(this); + var secondaryBrush = ThemeResourceHelper.Secondary(this); + + // 취소 버튼 + 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); + + // 기존 wrapper → editPanel 교체 + MessagePanel.Children[idx] = editPanel; + + // Enter 키로도 전송 + 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; + } + + // bubbleIndex에 해당하는 user 메시지 찾기 + int userMsgIdx = -1; + int uiIdx = 0; + lock (_convLock) + { + for (int 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) + { + conv.Messages[userMsgIdx].Content = newText; + while (conv.Messages.Count > userMsgIdx + 1) + conv.Messages.RemoveAt(conv.Messages.Count - 1); + } + + // UI에서 편집된 버블 이후 모두 제거 + while (MessagePanel.Children.Count > bubbleIndex + 1) + MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1); + + // 편집된 메시지를 새 버블로 교체 + MessagePanel.Children.RemoveAt(bubbleIndex); + AddMessageBubble("user", newText, animate: false); + + // AI 재응답 + await SendRegenerateAsync(conv); + try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } + RefreshConversationList(); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.Sending.cs b/src/AxCopilot/Views/ChatWindow.Sending.cs index 16f5450..408ab80 100644 --- a/src/AxCopilot/Views/ChatWindow.Sending.cs +++ b/src/AxCopilot/Views/ChatWindow.Sending.cs @@ -1,8 +1,6 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Input; -using System.Windows.Media; -using System.Windows.Media.Animation; using System.Windows.Threading; using AxCopilot.Models; using AxCopilot.Services; @@ -11,255 +9,6 @@ namespace AxCopilot.Views; public partial class ChatWindow { - // ─── 메시지 등장 애니메이션 ────────────────────────────────────────── - - 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; - - // wrapper 위치(인덱스) 기억 - var idx = MessagePanel.Children.IndexOf(wrapper); - if (idx < 0) { _isEditing = false; return; } - - // 편집 UI 생성 - var editPanel = new StackPanel - { - HorizontalAlignment = HorizontalAlignment.Right, - MaxWidth = 540, - Margin = wrapper.Margin, - }; - - var editBox = new TextBox - { - Text = originalText, - FontSize = 13.5, - Foreground = ThemeResourceHelper.Primary(this), - Background = ThemeResourceHelper.ItemBg(this), - CaretBrush = ThemeResourceHelper.Accent(this), - BorderBrush = ThemeResourceHelper.Accent(this), - 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 = ThemeResourceHelper.Accent(this); - var secondaryBrush = ThemeResourceHelper.Secondary(this); - - // 취소 버튼 - 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); - - // 기존 wrapper → editPanel 교체 - MessagePanel.Children[idx] = editPanel; - - // Enter 키로도 전송 - 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; - } - - // bubbleIndex에 해당하는 user 메시지 찾기 - int userMsgIdx = -1; - int uiIdx = 0; - lock (_convLock) - { - for (int 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) - { - conv.Messages[userMsgIdx].Content = newText; - while (conv.Messages.Count > userMsgIdx + 1) - conv.Messages.RemoveAt(conv.Messages.Count - 1); - } - - // UI에서 편집된 버블 이후 모두 제거 - while (MessagePanel.Children.Count > bubbleIndex + 1) - MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1); - - // 편집된 메시지를 새 버블로 교체 - MessagePanel.Children.RemoveAt(bubbleIndex); - AddMessageBubble("user", newText, animate: false); - - // AI 재응답 - await SendRegenerateAsync(conv); - try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } - RefreshConversationList(); - } - - // ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ──────────────────────────── - - private void StopAiIconPulse() - { - if (_aiIconPulseStopped || _activeAiIcon == null) return; - _activeAiIcon.BeginAnimation(UIElement.OpacityProperty, null); - _activeAiIcon.Opacity = 1.0; - _activeAiIcon = null; - _aiIconPulseStopped = true; - } - - private void CursorTimer_Tick(object? sender, EventArgs e) - { - _cursorVisible = !_cursorVisible; - // 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당 - if (_activeStreamText != null && _displayedLength > 0) - { - var displayed = _cachedStreamContent.Length > 0 - ? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)] - : ""; - _activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " "); - } - } - - private void ElapsedTimer_Tick(object? sender, EventArgs e) - { - var elapsed = DateTime.UtcNow - _streamStartTime; - var sec = (int)elapsed.TotalSeconds; - if (_elapsedLabel != null) - _elapsedLabel.Text = $"{sec}s"; - - // 하단 상태바 시간 갱신 - if (StatusElapsed != null) - StatusElapsed.Text = $"{sec}초"; - } - - private void TypingTimer_Tick(object? sender, EventArgs e) - { - if (_activeStreamText == null || string.IsNullOrEmpty(_cachedStreamContent)) return; - - var targetLen = _cachedStreamContent.Length; - if (_displayedLength >= targetLen) return; - - // 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응 - var pending = targetLen - _displayedLength; - int step; - if (pending > 200) step = Math.Min(pending / 5, 40); // 대량 버퍼: 빠르게 따라잡기 - else if (pending > 50) step = Math.Min(pending / 4, 15); // 중간 버퍼: 적당히 가속 - else step = Math.Min(3, pending); // 소량: 자연스러운 1~3자 - - _displayedLength += step; - - var displayed = _cachedStreamContent[.._displayedLength]; - _activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " "); - - // 스트리밍 중에는 즉시 스크롤 (부드러운 애니메이션은 지연 유발) - if (!_userScrolled) - MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight); - } - // ─── 전송 ────────────────────────────────────────────────────────────── public void SendInitialMessage(string message) @@ -456,7 +205,7 @@ public partial class ChatWindow { Source = thumbnail, MaxHeight = 48, MaxWidth = 64, - Stretch = Stretch.Uniform, + Stretch = System.Windows.Media.Stretch.Uniform, Margin = new Thickness(0, 0, 4, 0), }); } @@ -483,8 +232,8 @@ public partial class ChatWindow var removeBtn = new Button { Content = new TextBlock { Text = "\uE711", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 8, Foreground = secondaryBrush }, - Background = Brushes.Transparent, BorderThickness = new Thickness(0), - Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0), + Background = System.Windows.Media.Brushes.Transparent, BorderThickness = new Thickness(0), + Cursor = System.Windows.Input.Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0), }; removeBtn.Click += (_, _) => { diff --git a/src/AxCopilot/Views/ChatWindow.StreamingTimers.cs b/src/AxCopilot/Views/ChatWindow.StreamingTimers.cs new file mode 100644 index 0000000..32845ae --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.StreamingTimers.cs @@ -0,0 +1,66 @@ +using System.Windows; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + // ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ──────────────────────────── + + private void StopAiIconPulse() + { + if (_aiIconPulseStopped || _activeAiIcon == null) return; + _activeAiIcon.BeginAnimation(UIElement.OpacityProperty, null); + _activeAiIcon.Opacity = 1.0; + _activeAiIcon = null; + _aiIconPulseStopped = true; + } + + private void CursorTimer_Tick(object? sender, EventArgs e) + { + _cursorVisible = !_cursorVisible; + // 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당 + if (_activeStreamText != null && _displayedLength > 0) + { + var displayed = _cachedStreamContent.Length > 0 + ? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)] + : ""; + _activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " "); + } + } + + private void ElapsedTimer_Tick(object? sender, EventArgs e) + { + var elapsed = DateTime.UtcNow - _streamStartTime; + var sec = (int)elapsed.TotalSeconds; + if (_elapsedLabel != null) + _elapsedLabel.Text = $"{sec}s"; + + // 하단 상태바 시간 갱신 + if (StatusElapsed != null) + StatusElapsed.Text = $"{sec}초"; + } + + private void TypingTimer_Tick(object? sender, EventArgs e) + { + if (_activeStreamText == null || string.IsNullOrEmpty(_cachedStreamContent)) return; + + var targetLen = _cachedStreamContent.Length; + if (_displayedLength >= targetLen) return; + + // 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응 + var pending = targetLen - _displayedLength; + int step; + if (pending > 200) step = Math.Min(pending / 5, 40); // 대량 버퍼: 빠르게 따라잡기 + else if (pending > 50) step = Math.Min(pending / 4, 15); // 중간 버퍼: 적당히 가속 + else step = Math.Min(3, pending); // 소량: 자연스러운 1~3자 + + _displayedLength += step; + + var displayed = _cachedStreamContent[.._displayedLength]; + _activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " "); + + // 스트리밍 중에는 즉시 스크롤 (부드러운 애니메이션은 지연 유발) + if (!_userScrolled) + MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight); + } +}