- ChatWindow.MessageInteractions.cs를 추가해 좋아요·싫어요 피드백 버튼, 응답 메타 텍스트, 메시지 등장 애니메이션, 사용자 메시지 편집·재생성 흐름을 메인 창 코드에서 분리함 - ChatWindow.xaml.cs는 transcript 오케스트레이션과 상태 흐름에 더 집중하도록 정리해 claw-code 기준 renderer 분리 작업을 이어가기 쉬운 구조로 개선함 - README.md와 docs/DEVELOPMENT.md에 2026-04-06 08:27 (KST) 기준 변경 이력을 반영함 - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 기준 경고 0 / 오류 0 확인
This commit is contained in:
@@ -4249,336 +4249,6 @@ public partial class ChatWindow : Window
|
||||
return template;
|
||||
}
|
||||
|
||||
/// <summary>좋아요/싫어요 토글 피드백 버튼 (상태 영구 저장)</summary>
|
||||
private Button CreateFeedbackButton(string outline, string filled, string tooltip,
|
||||
Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "",
|
||||
Action? resetSibling = null, Action<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;
|
||||
}
|
||||
|
||||
/// <summary>좋아요/싫어요 버튼을 상호 배타로 연결하여 추가</summary>
|
||||
private void AddLinkedFeedbackButtons(StackPanel actionBar, Brush btnColor, ChatMessage? message)
|
||||
{
|
||||
// resetSibling는 나중에 설정되므로 Action 래퍼로 간접 참조
|
||||
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<string>();
|
||||
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;
|
||||
|
||||
// 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 = 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(
|
||||
"<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 메시지 찾기
|
||||
// UI의 children 중 user 메시지가 아닌 것(system)은 스킵됨
|
||||
// 데이터 모델에서 해당 위치의 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)
|
||||
{
|
||||
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();
|
||||
|
||||
// AI 재응답
|
||||
await SendRegenerateAsync(conv);
|
||||
try { if (ChatSession == null) _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
|
||||
RefreshConversationList();
|
||||
}
|
||||
|
||||
// ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user