From 6cc79cf3e52941ff1e8c555e44f5fe456c41cf8d Mon Sep 17 00:00:00 2001 From: lacvet Date: Sun, 5 Apr 2026 00:03:15 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AA=A8=EB=8D=B8=EB=B3=84=20compact=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EB=9F=89=20=EA=B3=84=EC=B8=A1=EA=B3=BC=20?= =?UTF-8?q?=EC=95=95=EC=B6=95=20=EA=B2=BD=EA=B3=84=20UI=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - claw-code compaction accounting 흐름을 참고해 service:model 기준 일별 토큰 집계와 compact 이후 모델 사용량 버킷을 추가함 - microcompact/session_memory/collapsed boundary 메시지를 전용 압축 카드로 렌더링하고 compact 관련 thinking 이벤트를 얇은 compact pill로 분리함 - README 및 docs/DEVELOPMENT.md에 2026-04-05 00:01 (KST) 기준 이력을 반영함 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0) --- README.md | 3 + docs/DEVELOPMENT.md | 2 + src/AxCopilot/Models/DailyUsageStats.cs | 16 ++ .../Services/UsageStatisticsService.cs | 32 +++- src/AxCopilot/Views/ChatWindow.xaml.cs | 156 +++++++++++++++++- 5 files changed, 207 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 33f8565..4702f6f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 - compact 직후 첫 응답을 별도로 추적하는 post-compaction 흐름을 추가해, 압축 다음 턴의 응답 횟수와 사용 토큰을 현재 대화 기준으로 누적 집계하도록 보강했습니다. - AX Agent 하단 컨텍스트 카드 hover에는 이제 `compact 후 첫 응답 대기 중`, `compact 이후 응답 수`, `compact 이후 사용 토큰`까지 함께 표시됩니다. +- 업데이트: 2026-04-05 00:01 (KST) +- 일별 사용 통계에 `service:model` 기준 토큰 집계를 추가해 일반 사용량과 compact 이후 사용량을 모델 단위로 나눠 기록하도록 보강했습니다. +- `microcompact_boundary`, `session_memory_compaction`, `collapsed_boundary` 메시지는 일반 AI 답변과 다른 전용 압축 카드로 렌더링하고, compact 관련 실행 로그는 얇은 compact pill로 분리해 노이즈를 줄였습니다. - 업데이트: 2026-04-04 23:32 (KST) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 23979d2..7a23912 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -3,6 +3,8 @@ - Document update: 2026-04-04 23:47 (KST) - Added AX-side session-memory compaction, collapse/snip trimming, and per-conversation compaction usage tracking so compact now records staged reductions instead of only the last before/after snapshot. - Document update: 2026-04-04 23:47 (KST) - The AX Agent context tooltip now shows session totals for compaction count, saved tokens, session-memory passes, microcompact boundaries, and snipped messages for the current conversation. - Document update: 2026-04-04 23:47 (KST) - Added a post-compaction flag and conversation-level follow-up usage counters so AX can distinguish the first response after compaction and report its prompt/completion token cost separately. +- Document update: 2026-04-05 00:01 (KST) - Added per-model daily token aggregation (`service:model`) plus post-compaction model usage buckets so AX can compare normal usage and compact-follow-up usage more like claw-code's compaction accounting. +- Document update: 2026-04-05 00:01 (KST) - Compact boundary messages now render as dedicated compact cards, and compact-related thinking events render as lightweight pills so the timeline distinguishes compression boundaries from ordinary assistant replies and general execution logs. - Document update: 2026-04-04 23:32 (KST) - Upgraded ContextCondenser from a two-step path to a three-step path (tool-result truncate -> microcompact -> summarize) so long sessions can strip older execution/tool chatter before invoking the LLM summary stage. - Document update: 2026-04-04 23:32 (KST) - Added AX-side microcompact boundaries that compress older tool results, execution metadata, and oversized messages into lighter boundary summaries, bringing the compact flow closer to the staged approach used in claude-code. diff --git a/src/AxCopilot/Models/DailyUsageStats.cs b/src/AxCopilot/Models/DailyUsageStats.cs index 91d96a1..f79d6b6 100644 --- a/src/AxCopilot/Models/DailyUsageStats.cs +++ b/src/AxCopilot/Models/DailyUsageStats.cs @@ -39,4 +39,20 @@ public class DailyUsageStats /// 일별 완료 토큰 수. [JsonPropertyName("completionTokens")] public long CompletionTokens { get; set; } + + /// 모델별 프롬프트 토큰 수. key = service:model + [JsonPropertyName("modelPromptTokens")] + public Dictionary ModelPromptTokens { get; set; } = new(); + + /// 모델별 완료 토큰 수. key = service:model + [JsonPropertyName("modelCompletionTokens")] + public Dictionary ModelCompletionTokens { get; set; } = new(); + + /// 모델별 compact 이후 프롬프트 토큰 수. key = service:model + [JsonPropertyName("postCompactionPromptTokens")] + public Dictionary PostCompactionPromptTokens { get; set; } = new(); + + /// 모델별 compact 이후 완료 토큰 수. key = service:model + [JsonPropertyName("postCompactionCompletionTokens")] + public Dictionary PostCompactionCompletionTokens { get; set; } = new(); } diff --git a/src/AxCopilot/Services/UsageStatisticsService.cs b/src/AxCopilot/Services/UsageStatisticsService.cs index 1b6b35f..67815b9 100644 --- a/src/AxCopilot/Services/UsageStatisticsService.cs +++ b/src/AxCopilot/Services/UsageStatisticsService.cs @@ -70,7 +70,7 @@ internal static class UsageStatisticsService } /// 토큰 사용량을 기록합니다. - public static void RecordTokens(int promptTokens, int completionTokens) + public static void RecordTokens(int promptTokens, int completionTokens, string? service = null, string? model = null, bool isPostCompaction = false) { if (promptTokens <= 0 && completionTokens <= 0) return; EnsureInitialized(); @@ -79,6 +79,23 @@ internal static class UsageStatisticsService _today.PromptTokens += promptTokens; _today.CompletionTokens += completionTokens; _today.TotalTokens += promptTokens + completionTokens; + + var key = BuildModelUsageKey(service, model); + if (!string.IsNullOrWhiteSpace(key)) + { + _today.ModelPromptTokens.TryGetValue(key, out var modelPrompt); + _today.ModelCompletionTokens.TryGetValue(key, out var modelCompletion); + _today.ModelPromptTokens[key] = modelPrompt + promptTokens; + _today.ModelCompletionTokens[key] = modelCompletion + completionTokens; + + if (isPostCompaction) + { + _today.PostCompactionPromptTokens.TryGetValue(key, out var postPrompt); + _today.PostCompactionCompletionTokens.TryGetValue(key, out var postCompletion); + _today.PostCompactionPromptTokens[key] = postPrompt + promptTokens; + _today.PostCompactionCompletionTokens[key] = postCompletion + completionTokens; + } + } } _ = SaveTodayAsync(); } @@ -205,8 +222,21 @@ internal static class UsageStatisticsService TotalTokens = _today.TotalTokens, PromptTokens = _today.PromptTokens, CompletionTokens = _today.CompletionTokens, + ModelPromptTokens = new Dictionary(_today.ModelPromptTokens), + ModelCompletionTokens = new Dictionary(_today.ModelCompletionTokens), + PostCompactionPromptTokens = new Dictionary(_today.PostCompactionPromptTokens), + PostCompactionCompletionTokens = new Dictionary(_today.PostCompactionCompletionTokens), }; private static string GetFilePath(string dateStr) => Path.Combine(_statsDir, $"{dateStr}.json"); + + private static string BuildModelUsageKey(string? service, string? model) + { + var normalizedService = (service ?? "").Trim().ToLowerInvariant(); + var normalizedModel = (model ?? "").Trim(); + if (string.IsNullOrWhiteSpace(normalizedService) || string.IsNullOrWhiteSpace(normalizedModel)) + return ""; + return $"{normalizedService}:{normalizedModel}"; + } } diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index ada096f..caa7605 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -4136,6 +4136,135 @@ public partial class ChatWindow : Window }; } + private static bool IsCompactionMetaMessage(ChatMessage? message) + { + var kind = message?.MetaKind ?? ""; + return kind.Equals("microcompact_boundary", StringComparison.OrdinalIgnoreCase) + || kind.Equals("session_memory_compaction", StringComparison.OrdinalIgnoreCase) + || kind.Equals("collapsed_boundary", StringComparison.OrdinalIgnoreCase); + } + + private Border CreateCompactionMetaCard(ChatMessage message, Brush primaryText, Brush secondaryText, Brush hintBg, Brush borderBrush, Brush accentBrush) + { + var icon = "\uE9CE"; + var title = message.MetaKind switch + { + "session_memory_compaction" => "세션 메모리 압축", + "collapsed_boundary" => "압축 경계 병합", + _ => "Microcompact 경계", + }; + + var wrapper = new Border + { + Background = hintBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(12, 10, 12, 10), + Margin = new Thickness(10, 4, 150, 4), + MaxWidth = GetMessageMaxWidth(), + HorizontalAlignment = HorizontalAlignment.Left, + }; + + var stack = new StackPanel(); + var header = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 0, 0, 6), + }; + header.Children.Add(new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center, + }); + header.Children.Add(new TextBlock + { + Text = title, + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + Margin = new Thickness(6, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + stack.Children.Add(header); + + var lines = (message.Content ?? "") + .Replace("\r\n", "\n") + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToList(); + + foreach (var line in lines) + { + var isHeaderLine = line.StartsWith("[", StringComparison.Ordinal); + stack.Children.Add(new TextBlock + { + Text = isHeaderLine ? line.Trim('[', ']') : line, + FontSize = isHeaderLine ? 10.5 : 10.5, + FontWeight = isHeaderLine ? FontWeights.SemiBold : FontWeights.Normal, + Foreground = isHeaderLine ? primaryText : secondaryText, + TextWrapping = TextWrapping.Wrap, + Margin = isHeaderLine ? new Thickness(0, 0, 0, 3) : new Thickness(0, 0, 0, 2), + }); + } + + if (!string.IsNullOrWhiteSpace(message.MetaRunId)) + { + stack.Children.Add(new TextBlock + { + Text = $"run {message.MetaRunId}", + FontSize = 9.5, + Foreground = secondaryText, + Opacity = 0.7, + Margin = new Thickness(0, 6, 0, 0), + }); + } + + wrapper.Child = stack; + return wrapper; + } + + private Border CreateCompactEventPill(string summary, Brush primaryText, Brush secondaryText, Brush hintBg, Brush borderBrush, Brush accentBrush) + { + return new Border + { + Background = hintBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(999), + Padding = new Thickness(10, 5, 10, 5), + Margin = new Thickness(10, 4, 150, 4), + HorizontalAlignment = HorizontalAlignment.Left, + Child = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = + { + new TextBlock + { + Text = "\uE9CE", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 10, + Foreground = accentBrush, + VerticalAlignment = VerticalAlignment.Center, + }, + new TextBlock + { + Text = summary, + FontSize = 10.5, + Foreground = secondaryText, + Margin = new Thickness(6, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + } + } + } + }; + } + private void AddMessageBubble(string role, string content, bool animate = true, ChatMessage? message = null) { var isUser = role == "user"; @@ -4227,6 +4356,14 @@ public partial class ChatWindow : Window } else { + if (message != null && IsCompactionMetaMessage(message)) + { + var compactCard = CreateCompactionMetaCard(message, primaryText, secondaryText, hintBg, borderBrush, accentBrush); + if (animate) ApplyMessageEntryAnimation(compactCard); + MessagePanel.Children.Add(compactCard); + return; + } + // 어시스턴트: 좌측 정렬, 정돈된 카드 var container = new StackPanel { @@ -10149,6 +10286,22 @@ public partial class ChatWindow : Window return; } + if (evt.Type == AgentEventType.Thinking && + ContainsAny(evt.Summary ?? "", "컨텍스트 압축", "microcompact", "session memory", "compact")) + { + var compactPrimaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var compactSecondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var compactHintBg = TryFindResource("HintBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + var compactBorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var compactAccentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var pill = CreateCompactEventPill(string.IsNullOrWhiteSpace(evt.Summary) ? "컨텍스트 압축" : evt.Summary, compactPrimaryText, compactSecondaryText, compactHintBg, compactBorderBrush, compactAccentBrush); + pill.Opacity = 0; + pill.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(160))); + MessagePanel.Children.Add(pill); + return; + } + // simple 모드: ToolCall은 건너뜀 (ToolResult만 한 줄로 표시) if (logLevel == "simple" && evt.Type == AgentEventType.ToolCall) return; @@ -10940,6 +11093,7 @@ public partial class ChatWindow : Window var usage = _llm.LastTokenUsage; // 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용 var isAgentTab = _activeTab is "Cowork" or "Code"; + var (usageService, usageModel) = _llm.GetCurrentModelInfo(); var displayInput = isAgentTab && _agentCumulativeInputTokens > 0 ? _agentCumulativeInputTokens : usage?.PromptTokens ?? 0; @@ -10951,7 +11105,7 @@ public partial class ChatWindow : Window if (displayInput > 0 || displayOutput > 0) { UpdateStatusTokens(displayInput, displayOutput); - Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput); + Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput, usageService, usageModel, wasPostCompactionResponse); ConsumePostCompactionUsageIfNeeded(displayInput, displayOutput); } string tokenText;