Some checks failed
Release Gate / gate (push) Has been cancelled
- 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 확인
395 lines
13 KiB
C#
395 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 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)
|
|
{
|
|
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;
|
|
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();
|
|
}
|
|
}
|