diff --git a/README.md b/README.md index 4d60f01..e9cc0d0 100644 --- a/README.md +++ b/README.md @@ -1647,3 +1647,7 @@ MIT License - `time-based` tool result 정리 기준을 모델/서비스별로 세분화해, Claude는 더 보수적으로, Qwen/vLLM 계열은 더 빠르게 오래된 결과를 걷어내도록 조정했습니다. - [ContextCondenser.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ContextCondenser.cs)는 `service:model` 조합에 따라 `gapThresholdMinutes`와 `keepRecent`를 다르게 계산하도록 바뀌었습니다. - [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs)는 compact 메타 카드를 긴 상세 줄 목록 대신 한 줄 요약 카드로 단순화해, transcript에서 운영성 카드 밀도를 더 낮췄습니다. +- 업데이트: 2026-04-12 22:27 (KST) + - compact 이후 응답/컨텍스트 사용 표시에서 남아 있던 후행 운영 메타를 더 줄였습니다. + - [ChatWindow.ResponsePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs)는 응답 하단 토큰 메타에서 `compact 직후` 꼬리표를 제거해, 일반 응답과 같은 밀도로 보이도록 정리했습니다. + - [ChatWindow.ContextUsagePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs)는 token usage 팝업 detail에서 `compact 후 첫 응답 대기 중` 문구를 빼고 실제 컨텍스트/압축 정보만 보여주도록 단순화했습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index fd190d6..7245030 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -645,3 +645,15 @@ owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript - compact 메타 카드를 긴 line-by-line 상세 표시 대신 제목 + 짧은 한 줄 설명으로 단순화했습니다. - `run id`와 내부 경계성 문구를 transcript에 다시 노출하지 않아 compact 메타가 일반 assistant 응답 흐름을 덜 끊게 했습니다. +## compact 후행 메타 정리 (2026-04-12 22:27 KST) + +- compact 직후 상태를 기록하는 내부 플래그는 유지하되, 사용자에게 보이는 응답 메타와 usage 팝업에서는 후행 운영 문구를 더 줄였습니다. +- `src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs` + - 응답 하단 토큰 메타에서 `compact 직후` 꼬리표를 제거했습니다. + - 실행/토큰 정보는 유지하면서도, 일반 응답과 운영 메타 응답이 시각적으로 다르게 보이지 않도록 맞췄습니다. +- `src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs` + - token usage 팝업의 detail 줄에서 `compact 후 첫 응답 대기 중` 문구를 제거하고, 실제 컨텍스트 사용량/압축 정보만 남겼습니다. +- 기대 효과 + - compact 이후에도 transcript와 usage UI가 일반 응답과 더 비슷한 밀도로 이어집니다. + - 운영 상태는 내부 로직/통계에만 남기고, 사용자 화면은 더 `claw-code` 스타일의 얇은 표현에 가까워집니다. + diff --git a/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs b/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs index 1e92c3f..c61474e 100644 --- a/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs @@ -52,7 +52,18 @@ public partial class ChatWindow var draftText = InputBox?.Text ?? ""; var draftTokens = string.IsNullOrWhiteSpace(draftText) ? 0 : Services.TokenEstimator.Estimate(draftText) + 4; - var currentTokens = Math.Max(0, messageTokens + draftTokens); + + // 시스템 프롬프트 + 도구 정의 오버헤드: 첫 메시지 전송 이후에만 포함 + // 새 대화(메시지 0개)에서는 0% 표시 — 사용자 혼동 방지 + var hasAnyMessages = messageTokens > 0 || _isStreaming; + int baseOverhead = 0; + if (hasAnyMessages) + { + var sysPromptLen = _llm?.SystemPrompt?.Length ?? 0; + var toolCount = _toolRegistry?.GetActiveToolsForTab(_activeTab ?? "Chat")?.Count ?? 0; + baseOverhead = Services.TokenEstimator.EstimateBaseOverhead(sysPromptLen, toolCount); + } + var currentTokens = Math.Max(0, messageTokens + draftTokens + baseOverhead); var usageRatio = Services.TokenEstimator.GetContextUsage(currentTokens, maxContextTokens); var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue; @@ -102,14 +113,15 @@ public partial class ChatWindow TokenUsageHintText.Text = $"{Services.TokenEstimator.Format(currentTokens)} / {Services.TokenEstimator.Format(maxContextTokens)}"; CompactNowLabel.Text = compactLabel; + ViewModel.ContextTokens = currentTokens; + ViewModel.MaxContextTokens = maxContextTokens; + if (TokenUsagePopupTitle != null) TokenUsagePopupTitle.Text = $"컨텍스트 창 {percentText}"; if (TokenUsagePopupUsage != null) TokenUsagePopupUsage.Text = $"{Services.TokenEstimator.Format(currentTokens)}/{Services.TokenEstimator.Format(maxContextTokens)}"; if (TokenUsagePopupDetail != null) - TokenUsagePopupDetail.Text = _pendingPostCompaction - ? $"compact 후 첫 응답 대기 중 · {detailText}" - : detailText; + TokenUsagePopupDetail.Text = detailText; if (TokenUsagePopupCompact != null) TokenUsagePopupCompact.Text = _sessionCompactionCount > 0 ? $"누적 압축 {_sessionCompactionCount}회 · 절감 {FormatTokenCount(_sessionCompactionSavedTokens)} tokens" diff --git a/src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs b/src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs new file mode 100644 index 0000000..1868684 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs @@ -0,0 +1,621 @@ +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; + + // claw-code처럼 메시지 축과 입력축이 같은 중심선을 공유하도록, + // 본문 폭 상한을 조금 더 낮추고 창 폭 변화에 더 부드럽게 반응시킵니다. + var contentWidth = Math.Max(360, viewportWidth - 24); + var messageWidth = Math.Clamp(contentWidth * 0.9, 360, 960); + var composerWidth = Math.Clamp(contentWidth * 0.86, 360, 900); + + if (contentWidth < 760) + { + messageWidth = Math.Clamp(contentWidth - 10, 344, 820); + composerWidth = Math.Clamp(contentWidth - 14, 340, 780); + } + + var changed = false; + if (Math.Abs(_lastResponsiveMessageWidth - messageWidth) > 1) + { + _lastResponsiveMessageWidth = messageWidth; + if (MessageList != null) + MessageList.MaxWidth = messageWidth + 48; + if (EmptyState != null) + EmptyState.MaxWidth = messageWidth; + changed = true; + } + + if (Math.Abs(_lastResponsiveComposerWidth - composerWidth) > 1) + { + _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 = 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); + + // 실시간 경과 시간 (헤더 우측) + _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, 2); + 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); + _draftQueueProcessor.ClearQueued(ChatSession, _activeTab, _storage); + _runningDraftId = null; + } + RefreshDraftQueueUi(); + + // 즉시 UI 상태 정리 — 에이전트 루프의 finally가 비동기로 도달할 때까지 대기하지 않음 + StopLiveAgentProgressHints(); + RemoveAgentLiveCard(); + HideStickyProgress(); + StopRainbowGlow(); + } + + // ─── 대화 내보내기 ────────────────────────────────────────────────── +}