- 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)
This commit is contained in:
@@ -39,4 +39,20 @@ public class DailyUsageStats
|
||||
/// <summary>일별 완료 토큰 수.</summary>
|
||||
[JsonPropertyName("completionTokens")]
|
||||
public long CompletionTokens { get; set; }
|
||||
|
||||
/// <summary>모델별 프롬프트 토큰 수. key = service:model</summary>
|
||||
[JsonPropertyName("modelPromptTokens")]
|
||||
public Dictionary<string, long> ModelPromptTokens { get; set; } = new();
|
||||
|
||||
/// <summary>모델별 완료 토큰 수. key = service:model</summary>
|
||||
[JsonPropertyName("modelCompletionTokens")]
|
||||
public Dictionary<string, long> ModelCompletionTokens { get; set; } = new();
|
||||
|
||||
/// <summary>모델별 compact 이후 프롬프트 토큰 수. key = service:model</summary>
|
||||
[JsonPropertyName("postCompactionPromptTokens")]
|
||||
public Dictionary<string, long> PostCompactionPromptTokens { get; set; } = new();
|
||||
|
||||
/// <summary>모델별 compact 이후 완료 토큰 수. key = service:model</summary>
|
||||
[JsonPropertyName("postCompactionCompletionTokens")]
|
||||
public Dictionary<string, long> PostCompactionCompletionTokens { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ internal static class UsageStatisticsService
|
||||
}
|
||||
|
||||
/// <summary>토큰 사용량을 기록합니다.</summary>
|
||||
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<string, long>(_today.ModelPromptTokens),
|
||||
ModelCompletionTokens = new Dictionary<string, long>(_today.ModelCompletionTokens),
|
||||
PostCompactionPromptTokens = new Dictionary<string, long>(_today.PostCompactionPromptTokens),
|
||||
PostCompactionCompletionTokens = new Dictionary<string, long>(_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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user