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:
@@ -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 후 첫 응답 대기 중` 문구를 빼고 실제 컨텍스트/압축 정보만 보여주도록 단순화했습니다.
|
||||
|
||||
@@ -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` 스타일의 얇은 표현에 가까워집니다.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
621
src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs
Normal file
621
src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs
Normal 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();
|
||||
}
|
||||
|
||||
// ─── 대화 내보내기 ──────────────────────────────────────────────────
|
||||
}
|
||||
Reference in New Issue
Block a user