Files
AX-Copilot-Codex/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs
lacvet 94dc325df4
Some checks failed
Release Gate / gate (push) Has been cancelled
메시지 좋아요 싫어요 토글 동작 수정
채팅 메시지 피드백 버튼 로직을 shared feedback 상태 기반으로 다시 구성해 좋아요와 싫어요가 모두 즉시 반응하고 상호배타적으로 동작하도록 정리했다.

좋아요는 활성 색상과 chip 배경으로 상태가 확실히 보이게 바꾸고, 싫어요를 다시 누르면 null 상태로 정상 해제되도록 수정했다.

README와 DEVELOPMENT 문서를 2026-04-06 18:09 (KST) 기준으로 갱신했고 dotnet build 검증에서 경고 0 / 오류 0을 확인했다.
2026-04-06 17:56:53 +09:00

438 lines
13 KiB
C#

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
{
/// <summary>좋아요/싫어요 피드백 버튼을 생성합니다.</summary>
private Button CreateFeedbackButton(
string iconGlyph,
string tooltip,
Brush normalColor,
Brush activeColor,
Func<bool> isActive,
Action toggle)
{
var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var activeBackground = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(18, 255, 255, 255));
var icon = new TextBlock
{
Text = iconGlyph,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
Foreground = normalColor,
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center,
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new ScaleTransform(1, 1)
};
var chip = new Border
{
Background = Brushes.Transparent,
BorderBrush = Brushes.Transparent,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(6, 4, 6, 4),
Margin = new Thickness(0, 0, 4, 0),
Child = icon
};
var btn = new Button
{
Content = chip,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
Padding = new Thickness(0),
ToolTip = tooltip
};
void RefreshVisual()
{
var active = isActive();
icon.Foreground = active ? activeColor : normalColor;
chip.Background = active ? activeBackground : Brushes.Transparent;
chip.BorderBrush = active ? activeColor : Brushes.Transparent;
}
RefreshVisual();
btn.MouseEnter += (_, _) =>
{
if (!isActive())
icon.Foreground = hoverBrush;
};
btn.MouseLeave += (_, _) =>
{
if (!isActive())
icon.Foreground = normalColor;
};
btn.Click += (_, _) =>
{
toggle();
RefreshVisual();
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)
{
string? currentFeedback = message?.Feedback;
void PersistFeedback()
{
if (message == null)
return;
try
{
var feedback = string.IsNullOrWhiteSpace(currentFeedback) ? null : currentFeedback;
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 likeBtn = CreateFeedbackButton(
"\uE8E1",
"좋아요",
btnColor,
new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)),
() => string.Equals(currentFeedback, "like", StringComparison.OrdinalIgnoreCase),
() =>
{
currentFeedback = string.Equals(currentFeedback, "like", StringComparison.OrdinalIgnoreCase)
? null
: "like";
PersistFeedback();
});
var dislikeBtn = CreateFeedbackButton(
"\uE8E0",
"싫어요",
btnColor,
new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E)),
() => string.Equals(currentFeedback, "dislike", StringComparison.OrdinalIgnoreCase),
() =>
{
currentFeedback = string.Equals(currentFeedback, "dislike", StringComparison.OrdinalIgnoreCase)
? null
: "dislike";
PersistFeedback();
});
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;
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(
"<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);
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();
}
}