compact 후행 운영 메타를 더 줄여 응답 표시를 단순화

- ResponsePresentation에서 compact 직후 꼬리표를 제거해 일반 응답과 같은 밀도로 보이도록 정리
- ContextUsagePresentation 팝업 detail에서 compact 후 첫 응답 대기 문구를 제거하고 실제 컨텍스트 정보만 남김
- README와 DEVELOPMENT 문서에 2026-04-12 22: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:
2026-04-12 21:56:25 +09:00
parent ef58e93e38
commit d8cd04aa4f
4 changed files with 653 additions and 4 deletions

View File

@@ -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 후 첫 응답 대기 중` 문구를 빼고 실제 컨텍스트/압축 정보만 보여주도록 단순화했습니다.

View File

@@ -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` 스타일의 얇은 표현에 가까워집니다.

View File

@@ -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"

View File

@@ -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);
}
/// <summary>"수정 후 재시도" — 피드백 입력 패널을 표시하고, 사용자 지시를 추가하여 재생성합니다.</summary>
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();
}
/// <summary>사용자 피드백과 함께 마지막 응답을 재생성합니다.</summary>
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,
"에이전트 작업 중...");
}
/// <summary>채팅 본문 폭을 세 탭에서 동일한 기준으로 맞춥니다.</summary>
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 };
}
/// <summary>AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴)</summary>
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;
}
/// <summary>토큰 수를 k/m 단위로 포맷</summary>
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(),
};
/// <summary>토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰)</summary>
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();
}
// ─── 대화 내보내기 ──────────────────────────────────────────────────
}