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; using AxCopilot.Services; using AxCopilot.Services.Agent; namespace AxCopilot.Views; public partial class ChatWindow { // ─── 응답 재생성 ────────────────────────────────────────────────────── private async Task RegenerateLastAsync() { if (_streamingTabs.Contains(_activeTab)) return; ChatConversation conv; lock (_convLock) { if (_currentConversation == null) return; conv = _currentConversation; } // 마지막 assistant 메시지 제거 lock (_convLock) { var session = ChatSession; if (session != null) { session.RemoveLastAssistantMessage(_activeTab, _storage); _currentConversation = session.CurrentConversation; conv = _currentConversation!; } else if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant") { conv.Messages.RemoveAt(conv.Messages.Count - 1); } } RenderMessages(preserveViewport: true); AutoScrollIfNeeded(); // 재전송 await SendRegenerateAsync(conv); } /// "수정 후 재시도" — 피드백 입력 패널을 표시하고, 사용자 지시를 추가하여 재생성합니다. private void ShowRetryWithFeedbackInput() { if (_streamingTabs.Contains(_activeTab)) return; var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)); var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; var container = new Border { Margin = new Thickness(40, 4, 40, 8), Padding = new Thickness(14, 10, 14, 10), CornerRadius = new CornerRadius(12), Background = itemBg, HorizontalAlignment = HorizontalAlignment.Stretch, }; var stack = new StackPanel(); stack.Children.Add(new TextBlock { Text = "어떻게 수정하면 좋을지 알려주세요:", FontSize = 12, Foreground = secondaryText, Margin = new Thickness(0, 0, 0, 6), }); var textBox = new TextBox { MinHeight = 38, MaxHeight = 80, AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, FontSize = 13, Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black, Foreground = primaryText, CaretBrush = primaryText, BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(10, 6, 10, 6), }; stack.Children.Add(textBox); var btnRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 8, 0, 0), }; var sendBtn = new Border { Background = accentBrush, CornerRadius = new CornerRadius(8), Padding = new Thickness(14, 6, 14, 6), Cursor = Cursors.Hand, Margin = new Thickness(6, 0, 0, 0), }; sendBtn.Child = new TextBlock { Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White }; sendBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85; sendBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0; sendBtn.MouseLeftButtonUp += (_, _) => { var feedback = textBox.Text.Trim(); if (string.IsNullOrEmpty(feedback)) return; RemoveTranscriptElement(container); _ = RetryWithFeedbackAsync(feedback); }; var cancelBtn = new Border { Background = Brushes.Transparent, CornerRadius = new CornerRadius(8), Padding = new Thickness(12, 6, 12, 6), Cursor = Cursors.Hand, }; cancelBtn.Child = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryText }; cancelBtn.MouseLeftButtonUp += (_, _) => RemoveTranscriptElement(container); btnRow.Children.Add(cancelBtn); btnRow.Children.Add(sendBtn); stack.Children.Add(btnRow); container.Child = stack; ApplyMessageEntryAnimation(container); AddTranscriptElement(container); ForceScrollToEnd(); textBox.Focus(); } /// 사용자 피드백과 함께 마지막 응답을 재생성합니다. private async Task RetryWithFeedbackAsync(string feedback) { if (_streamingTabs.Contains(_activeTab)) return; ChatConversation conv; lock (_convLock) { if (_currentConversation == null) return; conv = _currentConversation; } // 마지막 assistant 메시지 제거 lock (_convLock) { var session = ChatSession; if (session != null) { session.RemoveLastAssistantMessage(_activeTab, _storage); _currentConversation = session.CurrentConversation; conv = _currentConversation!; } else if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant") { conv.Messages.RemoveAt(conv.Messages.Count - 1); } } // 피드백을 사용자 메시지로 추가 var feedbackMsg = new ChatMessage { Role = "user", Content = $"[이전 응답에 대한 수정 요청] {feedback}\n\n위 피드백을 반영하여 다시 작성해주세요." }; lock (_convLock) { var session = ChatSession; if (session != null) { session.AppendMessage(_activeTab, feedbackMsg, _storage); _currentConversation = session.CurrentConversation; conv = _currentConversation!; } else { conv.Messages.Add(feedbackMsg); } } RenderMessages(preserveViewport: true); AutoScrollIfNeeded(); // 재전송 await SendRegenerateAsync(conv); } private async Task SendRegenerateAsync(ChatConversation conv) { var runTab = NormalizeTabName(conv.Tab); AxAgentExecutionEngine.PreparedExecution preparedExecution; lock (_convLock) preparedExecution = PrepareExecutionForConversation(conv, runTab, null); await ExecutePreparedTurnAsync( conv, runTab, conv.Tab ?? _activeTab, preparedExecution, "에이전트 작업 중..."); } /// 채팅 본문 폭을 세 탭에서 동일한 기준으로 맞춥니다. private double GetMessageMaxWidth() { var hostWidth = _lastResponsiveComposerWidth; if (hostWidth < 100) hostWidth = ComposerShell?.ActualWidth ?? 0; if (hostWidth < 100) hostWidth = MessageList?.ActualWidth ?? 0; if (hostWidth < 100) hostWidth = 1120; var maxW = hostWidth - 44; return Math.Clamp(maxW, 320, 760); } private bool UpdateResponsiveChatLayout() { var viewportWidth = MessageList?.ActualWidth ?? 0; if (viewportWidth < 200) viewportWidth = ActualWidth; if (viewportWidth < 200) return false; // 메시지 축과 입력축이 같은 중심선을 공유하도록 // 본문 폭 상한을 조금 더 낮추고 창 폭 변화에 더 부드럽게 반응시킵니다. var contentWidth = Math.Max(360, viewportWidth - 24); // 고정 최대폭 — 큰 창에서 안정적, 작은 창에서만 축소 var messageWidth = Math.Min(contentWidth - 10, 800); var composerWidth = Math.Min(contentWidth - 24, 760); var changed = false; if (Math.Abs(_lastResponsiveMessageWidth - messageWidth) > 8) { _lastResponsiveMessageWidth = messageWidth; if (MessageList != null) MessageList.MaxWidth = messageWidth + 48; if (EmptyState != null) EmptyState.MaxWidth = messageWidth; changed = true; } if (Math.Abs(_lastResponsiveComposerWidth - composerWidth) > 8) { _lastResponsiveComposerWidth = composerWidth; if (ComposerShell != null) { ComposerShell.Width = composerWidth; ComposerShell.MaxWidth = composerWidth; } changed = true; } return changed; } private StackPanel CreateStreamingContainer(out TextBlock streamText) { var msgMaxWidth = GetMessageMaxWidth(); var container = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, Width = msgMaxWidth, MaxWidth = msgMaxWidth, Margin = new Thickness(0, 3, 0, 3), Opacity = 0, RenderTransform = new TranslateTransform(0, 10) }; // 컨테이너 페이드인 + 슬라이드 업 container.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(280))); ((TranslateTransform)container.RenderTransform).BeginAnimation( TranslateTransform.YProperty, new DoubleAnimation(10, 0, TimeSpan.FromMilliseconds(300)) { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } }); var headerGrid = new Grid { Margin = new Thickness(2, 0, 0, 2) }; headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); var aiIcon = new TextBlock { Text = "\uE945", FontFamily = s_segoeIconFont, FontSize = 8, Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.Blue, VerticalAlignment = VerticalAlignment.Center }; // AI 아이콘 펄스 애니메이션 (응답 대기 중) aiIcon.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(700)) { AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever, EasingFunction = new SineEase() }); _activeAiIcon = aiIcon; Grid.SetColumn(aiIcon, 0); headerGrid.Children.Add(aiIcon); var (streamAgentName, _, _) = GetAgentIdentity(); var aiNameTb = new TextBlock { Text = streamAgentName, FontSize = 9, FontWeight = FontWeights.Medium, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, Margin = new Thickness(4, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center }; Grid.SetColumn(aiNameTb, 1); headerGrid.Children.Add(aiNameTb); // 유니코드 스피너 (에이전트 이름 옆) var spinChars = new[] { "\u00b7", "\u2722", "\u2733", "\u2736", "\u273b", "\u273d" }; var spinIndex = 0; var spinnerText = new TextBlock { Text = spinChars[0], FontFamily = new FontFamily("Consolas"), FontSize = 13, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(6, 0, 0, 0), }; spinnerText.SetResourceReference(TextBlock.ForegroundProperty, "AccentColor"); var spinTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(333) }; spinTimer.Tick += (_, _) => { spinIndex = (spinIndex + 1) % spinChars.Length; spinnerText.Text = spinChars[spinIndex]; }; spinTimer.Start(); _activeSpinnerTimer = spinTimer; Grid.SetColumn(spinnerText, 2); headerGrid.Children.Add(spinnerText); // 실시간 경과 시간 (헤더 우측) _elapsedLabel = new TextBlock { Text = "0s", FontSize = 9.5, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center, Opacity = 0.5, }; Grid.SetColumn(_elapsedLabel, 3); headerGrid.Children.Add(_elapsedLabel); container.Children.Add(headerGrid); var streamCard = new Border { Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White, BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(14), Padding = new Thickness(13, 10, 13, 10) }; streamText = new TextBlock { Text = "\u258c", FontSize = 12.5, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, TextWrapping = TextWrapping.Wrap, LineHeight = 20, }; streamCard.Child = streamText; container.Children.Add(streamCard); return container; } // ─── 스트리밍 완료 후 마크다운 렌더링으로 교체 ─────────────────────── private void FinalizeStreamingContainer(StackPanel container, TextBlock streamText, string finalContent, ChatMessage? message = null) { finalContent = string.IsNullOrWhiteSpace(finalContent) ? "(빈 응답)" : finalContent; // 스트리밍 plaintext 카드 제거 if (streamText.Parent is Border streamCard) container.Children.Remove(streamCard); else container.Children.Remove(streamText); // 마크다운 렌더링 var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray; var mdPanel = MarkdownRenderer.Render(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush); mdPanel.Margin = new Thickness(0, 0, 0, 4); mdPanel.Opacity = 0; var mdCard = new Border { Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White, BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(14), Padding = new Thickness(13, 10, 13, 10), Child = mdPanel, }; container.Children.Add(mdCard); mdPanel.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180))); // 액션 버튼 바 + 토큰 표시 var btnColor = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var capturedContent = finalContent; var actionBar = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(2, 2, 0, 0), Opacity = 0 }; actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () => { try { Clipboard.SetText(capturedContent); } catch { } })); actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync())); actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput())); AddLinkedFeedbackButtons(actionBar, btnColor, message); container.Children.Add(actionBar); container.MouseEnter += (_, _) => ShowMessageActionBar(actionBar); container.MouseLeave += (_, _) => HideMessageActionBarIfNotSelected(actionBar); container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, mdCard); // 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄) var elapsedText = TryGetStreamingElapsed(out var elapsed) ? (elapsed.TotalSeconds < 60 ? $"{elapsed.TotalSeconds:0.#}s" : $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s") : "0s"; var usage = _llm.LastTokenUsage; // 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용 var isAgentTab = _activeTab is "Cowork" or "Code"; var (usageService, usageModel) = _llm.GetCurrentModelInfo(); var tabCumIn = _tabCumulativeInputTokens.GetValueOrDefault(_activeTab); var tabCumOut = _tabCumulativeOutputTokens.GetValueOrDefault(_activeTab); var displayInput = isAgentTab && tabCumIn > 0 ? (int)tabCumIn : usage?.PromptTokens ?? 0; var displayOutput = isAgentTab && tabCumOut > 0 ? (int)tabCumOut : usage?.CompletionTokens ?? 0; var wasPostCompactionResponse = _pendingPostCompaction; if (displayInput > 0 || displayOutput > 0) { UpdateStatusTokens(displayInput, displayOutput); Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput, usageService, usageModel, wasPostCompactionResponse); ConsumePostCompactionUsageIfNeeded(displayInput, displayOutput); } string tokenText; if (displayInput > 0 || displayOutput > 0) tokenText = $"{FormatTokenCount(displayInput)} + {FormatTokenCount(displayOutput)} = {FormatTokenCount(displayInput + displayOutput)} tokens"; else if (usage != null) tokenText = $"{FormatTokenCount(usage.PromptTokens)} + {FormatTokenCount(usage.CompletionTokens)} = {FormatTokenCount(usage.TotalTokens)} tokens"; else tokenText = $"~{FormatTokenCount(EstimateTokenCount(finalContent))} tokens"; var metaText = new TextBlock { Text = $"{elapsedText} · {tokenText}", FontSize = 9.5, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 4, 0, 0), Opacity = 0.55, }; container.Children.Add(metaText); // Suggestion chips — AI가 번호 선택지를 제시한 경우 클릭 가능 버튼 표시 var chips = ParseSuggestionChips(finalContent); if (chips.Count > 0) { var chipPanel = new WrapPanel { Margin = new Thickness(0, 8, 0, 4), HorizontalAlignment = HorizontalAlignment.Left, }; foreach (var (num, label) in chips) { var chipBorder = new Border { Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent, BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(16), Padding = new Thickness(14, 7, 14, 7), Margin = new Thickness(0, 0, 8, 6), Cursor = Cursors.Hand, RenderTransformOrigin = new Point(0.5, 0.5), RenderTransform = new ScaleTransform(1, 1), }; chipBorder.Child = new TextBlock { Text = $"{num}. {label}", FontSize = 12.5, Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, }; var chipHover = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent; var chipNormal = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent; chipBorder.MouseEnter += (s, _) => { if (s is Border b && b.RenderTransform is ScaleTransform st) { st.ScaleX = 1.02; st.ScaleY = 1.02; b.Background = chipHover; } }; chipBorder.MouseLeave += (s, _) => { if (s is Border b && b.RenderTransform is ScaleTransform st) { st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = chipNormal; } }; var capturedLabel = $"{num}. {label}"; var capturedPanel = chipPanel; chipBorder.MouseLeftButtonDown += (_, _) => { // 칩 패널 제거 (1회용) if (capturedPanel.Parent is Panel parent) parent.Children.Remove(capturedPanel); // 선택한 옵션을 사용자 메시지로 전송 InputBox.Text = capturedLabel; _ = SendMessageAsync(); }; chipPanel.Children.Add(chipBorder); } container.Children.Add(chipPanel); } // 완성된 메시지 컨테이너를 GPU 비트맵으로 캐시 → 스크롤 시 레이아웃 재계산 없이 렌더링 container.CacheMode = new System.Windows.Media.BitmapCache(1.0) { SnapsToDevicePixels = true }; } /// AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴) private static List<(string Num, string Label)> ParseSuggestionChips(string content) { var chips = new List<(string, string)>(); if (string.IsNullOrEmpty(content)) return chips; var lines = content.Split('\n'); // 마지막 번호 목록 블록을 찾음 (연속된 번호 라인) var candidates = new List<(string, string)>(); var lastBlockStart = -1; for (int i = 0; i < lines.Length; i++) { var line = lines[i].Trim(); // "1. xxx", "2) xxx", "① xxx" 등 번호 패턴 var m = System.Text.RegularExpressions.Regex.Match(line, @"^(\d+)[.\)]\s+(.+)$"); if (m.Success) { if (lastBlockStart < 0 || i == lastBlockStart + candidates.Count) { if (lastBlockStart < 0) { lastBlockStart = i; candidates.Clear(); } candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd())); } else { // 새로운 블록 시작 lastBlockStart = i; candidates.Clear(); candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd())); } } else if (!string.IsNullOrWhiteSpace(line)) { // 번호 목록이 아닌 줄이 나오면 블록 리셋 lastBlockStart = -1; candidates.Clear(); } // 빈 줄은 블록 유지 (번호 목록 사이 빈 줄 허용) } // 2개 이상 선택지, 10개 이하일 때만 chips로 표시 if (candidates.Count >= 2 && candidates.Count <= 10) chips.AddRange(candidates); return chips; } /// 토큰 수를 k/m 단위로 포맷 private static string FormatTokenCount(int count) => count switch { >= 1_000_000 => $"{count / 1_000_000.0:0.#}m", >= 1_000 => $"{count / 1_000.0:0.#}k", _ => count.ToString(), }; /// 토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰) private static int EstimateTokenCount(string text) { if (string.IsNullOrEmpty(text)) return 0; // 한국어 문자 비율에 따라 가중 int cjk = 0; foreach (var c in text) if (c >= 0xAC00 && c <= 0xD7A3 || c >= 0x3000 && c <= 0x9FFF) cjk++; double ratio = text.Length > 0 ? (double)cjk / text.Length : 0; double charsPerToken = 4.0 - ratio * 2.0; // 영어 4, 한국어 2 return Math.Max(1, (int)Math.Round(text.Length / charsPerToken)); } // ─── 생성 중지 ────────────────────────────────────────────────────── private void StopGeneration() { // 현재 활성 탭이 스트리밍 중이면 그 탭만 취소; 아니면 모두 취소 if (_tabStreamCts.TryGetValue(_activeTab, out var activeCts)) activeCts.Cancel(); else foreach (var cts in _tabStreamCts.Values) cts.Cancel(); // 대기열 정리: 실행 중인 항목만 취소, 대기 중 항목은 보존 lock (_convLock) { _draftQueueProcessor.CancelRunning(ChatSession, _activeTab, _storage); // Queue preserved — user can manually clear or items will execute on next send // _draftQueueProcessor.ClearQueued(ChatSession, _activeTab, _storage); _runningDraftId = null; } RefreshDraftQueueUi(); // 즉시 UI 상태 정리 — 에이전트 루프의 finally가 비동기로 도달할 때까지 대기하지 않음 StopLiveAgentProgressHints(); RemoveAgentLiveCard(); HideStickyProgress(); StopRainbowGlow(); } // ─── 대화 내보내기 ────────────────────────────────────────────────── }