[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:
201
src/AxCopilot/Views/ChatWindow.MessageEdit.cs
Normal file
201
src/AxCopilot/Views/ChatWindow.MessageEdit.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using System.Windows.Media;
|
|
||||||
using System.Windows.Media.Animation;
|
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
using AxCopilot.Models;
|
using AxCopilot.Models;
|
||||||
using AxCopilot.Services;
|
using AxCopilot.Services;
|
||||||
@@ -11,255 +9,6 @@ namespace AxCopilot.Views;
|
|||||||
|
|
||||||
public partial class ChatWindow
|
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)
|
public void SendInitialMessage(string message)
|
||||||
@@ -456,7 +205,7 @@ public partial class ChatWindow
|
|||||||
{
|
{
|
||||||
Source = thumbnail,
|
Source = thumbnail,
|
||||||
MaxHeight = 48, MaxWidth = 64,
|
MaxHeight = 48, MaxWidth = 64,
|
||||||
Stretch = Stretch.Uniform,
|
Stretch = System.Windows.Media.Stretch.Uniform,
|
||||||
Margin = new Thickness(0, 0, 4, 0),
|
Margin = new Thickness(0, 0, 4, 0),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -483,8 +232,8 @@ public partial class ChatWindow
|
|||||||
var removeBtn = new Button
|
var removeBtn = new Button
|
||||||
{
|
{
|
||||||
Content = new TextBlock { Text = "\uE711", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 8, Foreground = secondaryBrush },
|
Content = new TextBlock { Text = "\uE711", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 8, Foreground = secondaryBrush },
|
||||||
Background = Brushes.Transparent, BorderThickness = new Thickness(0),
|
Background = System.Windows.Media.Brushes.Transparent, BorderThickness = new Thickness(0),
|
||||||
Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0),
|
Cursor = System.Windows.Input.Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0),
|
||||||
};
|
};
|
||||||
removeBtn.Click += (_, _) =>
|
removeBtn.Click += (_, _) =>
|
||||||
{
|
{
|
||||||
|
|||||||
66
src/AxCopilot/Views/ChatWindow.StreamingTimers.cs
Normal file
66
src/AxCopilot/Views/ChatWindow.StreamingTimers.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user