[Phase49] ChatWindow.Sending.cs 분리 — MessageEdit, StreamingTimers 분할

변경 파일:
- ChatWindow.Sending.cs: 720 → ~300줄 (전송 섹션만 유지)
- ChatWindow.MessageEdit.cs (신규): ApplyMessageEntryAnimation, _isEditing 필드,
  EnterEditMode, SubmitEditAsync (~180줄)
- ChatWindow.StreamingTimers.cs (신규): StopAiIconPulse, CursorTimer_Tick,
  ElapsedTimer_Tick, TypingTimer_Tick (~58줄)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 21:18:27 +09:00
parent 39e07dd947
commit c4f23eb2b0
3 changed files with 270 additions and 254 deletions

View File

@@ -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(
"<ControlTemplate xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' TargetType='Button'>" +
"<Border Background='" + (accentBrush is SolidColorBrush sb ? sb.Color.ToString() : "#4B5EFC") + "' CornerRadius='8' Padding='12,5,12,5'>" +
"<TextBlock Text='전송' FontSize='12' Foreground='White' HorizontalAlignment='Center'/>" +
"</Border></ControlTemplate>");
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();
}
}

View File

@@ -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(
"<ControlTemplate xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' TargetType='Button'>" +
"<Border Background='" + (accentBrush is SolidColorBrush sb ? sb.Color.ToString() : "#4B5EFC") + "' CornerRadius='8' Padding='12,5,12,5'>" +
"<TextBlock Text='전송' FontSize='12' Foreground='White' HorizontalAlignment='Center'/>" +
"</Border></ControlTemplate>");
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 += (_, _) =>
{

View File

@@ -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);
}
}