This commit is contained in:
2026-07-06 13:48:26 +02:00
parent 241dc2b987
commit 2ada187ae0
11 changed files with 2216 additions and 551 deletions
+834
View File
@@ -0,0 +1,834 @@
using AnotherReplayReader.Apm;
using AnotherReplayReader.ReplayFile;
using AnotherReplayReader.Utils;
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.Tracing;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
using System.Windows.Threading;
using TechnologyAssembler.Core.Relo;
using static AnotherReplayReader.AIProviderSettingsControl;
using static AnotherReplayReader.Utils.AIAnalyzeUI;
using static System.Net.Mime.MediaTypeNames;
namespace AnotherReplayReader
{
public partial class AIChatPanel : UserControl
{
private class OnDisposeAction(Action action) : IDisposable
{
private Action? _action = action ?? throw new ArgumentNullException(nameof(action));
public void Dispose()
{
if (_action is { } action)
{
_action = null;
action();
}
}
}
public record ParagraphStyle(double FontSize, Brush Foreground);
private delegate void UpdateCollapsibleSection(string title, bool collapse);
public static readonly FontFamily DefaultFontFamily = new("Microsoft YaHei UI");
public static readonly int DefaultFontSize = 14;
public static readonly int DetailsFontSize = 12;
public static readonly ParagraphStyle ContentStyle = new(DefaultFontSize, Brushes.Black);
public static readonly ParagraphStyle CollapsibleSectionHeaderStyle = new(DefaultFontSize, Brushes.DimGray);
public static readonly ParagraphStyle CollapsibleSectionHeaderHoverStyle = new(DefaultFontSize, Brushes.DodgerBlue);
public static readonly ParagraphStyle ThinkStyle = new(DetailsFontSize, Brushes.DimGray);
public static readonly ParagraphStyle LogStyle = new(DetailsFontSize, Brushes.DimGray);
public static readonly ParagraphStyle ErrorStyle = new(DefaultFontSize, Brushes.Red);
// ---------- fields ----------
private FlowDocument _document;
private ScrollViewer? _scrollViewer;
// 正在进行的请求相关
private CancellationTokenSource? _internalCts;
private CancellationToken? _externalCancelToken;
private CancellationTokenSource? _linkedCts;
private AIAnalyze? _analyzer;
private TimeIndexedPrefixSums? _eventCounts;
// 分析状态
private bool _isRunning;
private bool _isFailed;
private bool _canRetry;
// 回滚点
private int _blockCountBeforeSegment;
private int _lastSuccessfulSegment = -1;
// 思考块
private UpdateCollapsibleSection? _updateCurrentThinkingSection;
private DateTime _thinkingStartTime;
private bool _thinkBlockWasExpanded = false;
// 输出段落
private (Paragraph? Think, Paragraph? Content)? _currentContent;
// 进度计时
private TimeSpan _previousTotalTime;
private int _previousTotalChars;
private DateTimeOffset _analysisStartTime;
private int _currentOutputChars;
private EmaSpeed? _emaSpeed;
private DispatcherTimer? _uiTimer;
// 缓冲区
private readonly ConcurrentQueue<(AIAnalyze.AIChunk Chunk, DateTimeOffset Time)> _chunkQueue = new();
// Token 累计
private int _totalPromptTokens;
private int _totalCompletionTokens;
// ---------- properties ----------
// 外部注入:每次请求前调用获取最新配置
// 委托类型变更
public Func<AiRequestContext>? GetRequestContext { get; set; }
// ---------- methods ----------
// ---- constructor & reset ----
public AIChatPanel()
{
InitializeComponent();
_document = new FlowDocument
{
FontFamily = DefaultFontFamily,
FontSize = DefaultFontSize,
PagePadding = new Thickness(0),
ColumnWidth = 999999,
TextAlignment = TextAlignment.Left,
};
TextOptions.SetTextFormattingMode(_document, TextFormattingMode.Display);
TextOptions.SetTextRenderingMode(_document, TextRenderingMode.ClearType);
_outputViewer.Document = _document;
_outputViewer.Loaded += OnOutputViewerLoaded;
ResetInternal();
}
private void OnOutputViewerLoaded(object? sender, RoutedEventArgs ea)
{
ScrollViewer? GetScrollViewer(DependencyObject dep)
{
if (dep is ScrollViewer sv)
{
return sv;
}
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(dep); ++i)
{
var child = VisualTreeHelper.GetChild(dep, i);
var result = GetScrollViewer(child);
if (result != null)
{
return result;
}
}
return null;
}
_scrollViewer = GetScrollViewer(_outputViewer);
}
public void Reset()
{
if (_isRunning)
{
_internalCts?.Cancel();
}
ResetInternal();
}
private void ResetInternal()
{
_isRunning = false;
_isFailed = false;
_canRetry = false;
_lastSuccessfulSegment = -1;
_blockCountBeforeSegment = 0;
_analyzer = null;
_eventCounts = null;
_document.Blocks.Clear();
_updateCurrentThinkingSection = null;
_thinkBlockWasExpanded = false;
_currentContent = null;
_previousTotalTime = TimeSpan.Zero;
_previousTotalChars = 0;
_analysisStartTime = DateTimeOffset.UtcNow;
_currentOutputChars = 0;
_phaseText.Text = string.Empty;
_timeText.Text = string.Empty;
_charProgressText.Text = string.Empty;
_instantSpeedText.Text = string.Empty;
_avgSpeedText.Text = string.Empty;
_currentTokensText.Text = string.Empty;
_conversationTokensText.Text = string.Empty;
_totalPromptTokens = 0;
_totalCompletionTokens = 0;
StopUiTimer();
UpdateButtons();
}
// ---- public API ----
internal async Task StartAnalysisAsync(
Replay replay,
ImmutableSortedDictionary<int, Player> players,
string replayData,
TimeIndexedPrefixSums eventCounts,
CancellationToken externalToken)
{
if (_isRunning)
{
return;
}
ResetInternal();
_isRunning = true;
UpdateButtons();
// 配置委托必须存在
if (GetRequestContext is null)
{
AddErrorBlock("[错误] 未配置 GetConfig 委托");
_phaseText.Text = "分析失败";
_isRunning = false;
_isFailed = true;
UpdateButtons();
return;
}
// 链接取消令牌
_internalCts = new CancellationTokenSource();
_externalCancelToken = externalToken;
_linkedCts = CancellationTokenSource.CreateLinkedTokenSource(externalToken, _internalCts.Token);
// 记录初始回滚点(日志块之后)
_blockCountBeforeSegment = _document.Blocks.Count;
// 启动 UI 计时器
StartUiTimer();
// 整体分析
try
{
using var onExit = new OnDisposeAction(() =>
{
FinishCurrentContent();
// invoke UI update one last time to ensure final state is reflected
OnUiTimerTick(null, EventArgs.Empty);
// ensure UI timer is stopped before catch or finally blocks
StopUiTimer();
});
_analyzer = new AIAnalyze();
_eventCounts = eventCounts;
FinishCurrentContent();
var requestContext = GetRequestContext();
var systemPrompt = AIAnalyze.GetSystemPrompt(replay, players);
var userPrompt = AIAnalyze.BuildUserPrompt(replay.Mod, players, replayData, out var userPromptPrefix);
AppendLog($"让 AI 了解录像...",
userPromptPrefix
+ "[输入:操作信息流水账]\r\n"
+ $"[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
true);
#region info
var (systemPromptSize, systemPromptTokenCount) = AIAnalyze.EstimateTokenCount(systemPrompt);
var (userPromptSize, userPromptTokenCount) = AIAnalyze.EstimateTokenCount(userPrompt);
var totalPromptSize = systemPromptSize + userPromptSize;
var totalPromptTokenCount = systemPromptTokenCount + userPromptTokenCount;
var estimateText = $"系统提示词: {FormatNumber(systemPromptSize, true, "B")},约 {FormatNumber(systemPromptTokenCount, false)} Token\r\n" +
$"初始用户提示词: {FormatNumber(userPromptSize, true, "B")},约 {FormatNumber(userPromptTokenCount, false)} Token\r\n" +
$"总大小: {FormatNumber(totalPromptSize, true, "B")},约 {FormatNumber(totalPromptTokenCount, false)} Token";
AppendLog("初始阶段 Token 用量预测", estimateText, false);
#endregion
var result = await _analyzer.AnalyzeAsync(
systemPrompt,
userPrompt,
requestContext,
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(result);
var eventCountsBuilder = new StringBuilder();
foreach (var (Start, End, Description) in result.State.Segments)
{
var count = eventCounts.Query(Start, End);
eventCountsBuilder.AppendLine($"[{Start:mm\\:ss\\.ff} - {End:mm\\:ss\\.ff}],事件数: {count}");
}
AppendLog("各分段事件数量", eventCountsBuilder.ToString(), false);
// 分析完成,分段列表就绪
_lastSuccessfulSegment = 0;
// 分段分析循环
await ProcessSegmentsAsync(result.State, eventCounts);
// 总结
await ProcessSummaryAsync(eventCounts);
// 成功结束
_phaseText.Text = "分析完成";
AppendLog("分析完成。", null, true);
}
catch (OperationCanceledException)
{
AddErrorBlock("[已取消]");
_phaseText.Text = "分析已取消";
_isFailed = true;
_canRetry = (_analyzer != null);
}
catch (Exception ex)
{
AddErrorBlock($"[错误] {ex.Message}");
_phaseText.Text = "分析失败";
_isFailed = true;
_canRetry = (_analyzer != null); // 如果 analyzer 仍可用,允许重试
}
finally
{
_isRunning = false;
StopUiTimer();
UpdateButtons();
_linkedCts?.Dispose();
_linkedCts = null;
_internalCts?.Dispose();
_internalCts = null;
}
}
// ---- 分析阶段 ----
private async Task ProcessSegmentsAsync(AIAnalyze.State lastState,
TimeIndexedPrefixSums eventCounts)
{
_analyzer!.SetState(lastState);
while (lastState.CurrentSegment < lastState.Segments.Count)
{
if (_linkedCts is null)
{
break;
}
_linkedCts.Token.ThrowIfCancellationRequested();
var seg = lastState.Segments[lastState.CurrentSegment];
var segmentIndex = lastState.CurrentSegment + 1;
var totalSegments = lastState.Segments.Count;
_phaseText.Text = $"正在分析第 {segmentIndex}/{totalSegments} 段";
FinishCurrentContent();
// 记录回滚点
_blockCountBeforeSegment = _document.Blocks.Count;
// 计算事件数
var eventCount = eventCounts.Query(seg.Start, seg.End);
var segmentPrompt = AIAnalyze.BuildSegmentUserPrompt(lastState.Segments, lastState.CurrentSegment, eventCount);
var requestContext = GetRequestContext!();
AppendLog($"让 AI 分析第{segmentIndex}段...",
segmentPrompt
+ $"\r\n[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
true);
var segmentResult = await _analyzer.ContinueAnalyzeAsync(
segmentPrompt,
requestContext,
OnChunk,
_linkedCts.Token);
_lastSuccessfulSegment = lastState.CurrentSegment;
lastState = segmentResult.State;
UpdateTokenDisplay(segmentResult);
AppendLog($"第{segmentIndex}段完成。", null, true);
}
}
private async Task ProcessSummaryAsync(TimeIndexedPrefixSums eventCounts)
{
if (_linkedCts is null)
{
return;
}
_linkedCts.Token.ThrowIfCancellationRequested();
_phaseText.Text = "正在生成总结...";
FinishCurrentContent();
_blockCountBeforeSegment = _document.Blocks.Count;
var totalEvents = eventCounts.GetTotal();
var finalPrompt = AIAnalyze.BuildFinalUserPrompt(totalEvents);
var requestContext = GetRequestContext!();
AppendLog($"让 AI 生成总结...",
finalPrompt
+ $"\r\n[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
true);
var result = await _analyzer!.FinishAnalyzeAsync(
finalPrompt,
requestContext,
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(result);
}
// ---- 块追加与折叠 ----
private void OnChunk(AIAnalyze.AIChunk chunk)
{
_chunkQueue.Enqueue((chunk, DateTimeOffset.UtcNow));
}
private void StartThinkingBlock()
{
_thinkingStartTime = DateTime.Now;
var (_, content, update) = CreateCollapsibleSection("💭 AI 思考中...", collapsedByDefault: false);
_currentContent = (Think: content, _currentContent?.Content);
_updateCurrentThinkingSection = update;
_thinkBlockWasExpanded = true;
}
private void EndThinkingBlock()
{
_thinkBlockWasExpanded = false;
if (_updateCurrentThinkingSection is null)
{
return;
}
var elapsed = (DateTime.Now - _thinkingStartTime).TotalSeconds;
var timeText = elapsed < 60
? $"{elapsed:F0} 秒"
: $"{elapsed / 60:F1} 分钟";
// 自动折叠:移除段落,更新按钮文字
_updateCurrentThinkingSection($"💭 AI 已思考完毕(用时 {timeText}", true);
_updateCurrentThinkingSection = null;
}
private void StartContentParagraph()
{
_currentContent = (_currentContent?.Think, Content: new Paragraph());
_document.Blocks.Add(_currentContent.Value.Content);
}
private void AppendToParagraph(Paragraph? paragraph, string text, ParagraphStyle style)
{
if (paragraph is null)
{
Debug.Instance.DebugMessage += "[AIChatPanel] AppendToParagraph: paragraph is null, creating a new content paragraph.\r\n";
// 未预期的情况,创建一个新的内容段落
StartContentParagraph();
paragraph = _currentContent!.Value.Content!;
}
paragraph.Inlines.Add(new Run(text)
{
Foreground = style.Foreground,
FontSize = style.FontSize
});
}
// ---- 折叠块辅助 ----
private (Section Section, Paragraph Content, UpdateCollapsibleSection UpdateSection)
CreateCollapsibleSection(string title, bool collapsedByDefault)
{
var currentTitle = title;
var currentlyCollapsed = collapsedByDefault;
var section = new Section();
var header = new Paragraph();
var content = new Paragraph();
var hyperLink = new Hyperlink(new Run(""))
{
Foreground = CollapsibleSectionHeaderStyle.Foreground,
FontSize = CollapsibleSectionHeaderStyle.FontSize,
TextDecorations = null,
};
section.Blocks.Add(header);
section.Blocks.Add(content);
header.Inlines.Add(hyperLink);
void UpdateCollapsibleSection(string newTitle, bool willBeCollapsed)
{
currentTitle = newTitle;
currentlyCollapsed = willBeCollapsed;
var run = (Run)hyperLink.Inlines.FirstInline;
run.Text = currentlyCollapsed ? $"{currentTitle} ⯈" : $"{currentTitle} ⯆";
if (currentlyCollapsed)
{
section.Blocks.Remove(content);
}
else
{
if (!section.Blocks.Contains(content))
{
section.Blocks.Add(content);
}
}
}
hyperLink.Click += (s, e) =>
{
UpdateCollapsibleSection(currentTitle, !currentlyCollapsed);
};
hyperLink.MouseEnter += (s, e) =>
{
hyperLink.Foreground = CollapsibleSectionHeaderHoverStyle.Foreground;
};
hyperLink.MouseLeave += (s, e) =>
{
hyperLink.Foreground = CollapsibleSectionHeaderStyle.Foreground;
};
UpdateCollapsibleSection(currentTitle, collapsedByDefault);
_document.Blocks.Add(section);
return (section, content, UpdateCollapsibleSection);
}
private void AppendLog(string title, string? details, bool collapsed)
{
AutoScroll();
FinishCurrentContent();
title = $"📋 {title}";
if (string.IsNullOrEmpty(details))
{
var paragraph = new Paragraph(new Run(title)
{
Foreground = LogStyle.Foreground,
});
_document.Blocks.Add(paragraph);
return;
}
var logParagraph = CreateCollapsibleSection(title, collapsed).Content;
logParagraph.Inlines.Add(new Run(details)
{
Foreground = LogStyle.Foreground,
FontSize = LogStyle.FontSize
});
}
// ---- UI 更新与计时 ----
private void StartUiTimer()
{
_analysisStartTime = DateTimeOffset.UtcNow;
_currentOutputChars = 0;
_emaSpeed = new EmaSpeed();
_uiTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(100)
};
_uiTimer.Tick += OnUiTimerTick;
_uiTimer.Start();
}
private void StopUiTimer()
{
// discard everything in _chunkQueue
while (_chunkQueue.TryDequeue(out _)) { }
if (_uiTimer is not null)
{
_uiTimer.Stop();
_uiTimer.Tick -= OnUiTimerTick;
_uiTimer = null;
_previousTotalTime += DateTimeOffset.UtcNow - _analysisStartTime;
_previousTotalChars += _currentOutputChars;
}
}
private void OnUiTimerTick(object? sender, EventArgs e)
{
FlushPendingChunks();
var now = DateTime.Now;
var elapsed = now - _analysisStartTime;
var totalElapsed = _previousTotalTime + elapsed;
var totalChars = _previousTotalChars + _currentOutputChars;
_timeText.Text = $"{elapsed:mm\\:ss}";
_charProgressText.Text = $"总字数: {totalChars}";
if (_emaSpeed is not null)
{
var speed = _emaSpeed.GetDisplaySpeed(now);
var avgSpeed = elapsed.TotalSeconds > 0
? totalChars / totalElapsed.TotalSeconds
: 0;
_instantSpeedText.Text = $"{speed:0} 字/秒";
_avgSpeedText.Text = $"平均 {avgSpeed:0.00} 字/秒";
}
}
private void FinishCurrentContent()
{
FlushPendingChunks();
_currentContent = null;
}
private void FlushPendingChunks()
{
AutoScroll();
var thinkSb = new StringBuilder();
var contentSb = new StringBuilder();
var errorSb = new StringBuilder();
while (_chunkQueue.TryDequeue(out var data))
{
var (chunk, time) = data;
if (chunk.Type == AIAnalyze.AIChunkType.Reasoning)
{
if (_currentContent?.Think is null)
{
StartThinkingBlock();
}
thinkSb.Append(chunk.Text);
_currentOutputChars += chunk.Text.Length;
_emaSpeed?.ProcessEvent(chunk.Text.Length, time);
}
else if (chunk.Type == AIAnalyze.AIChunkType.Content)
{
if (_currentContent?.Content is null)
{
StartContentParagraph();
}
contentSb.Append(chunk.Text);
_currentOutputChars += chunk.Text.Length;
_emaSpeed?.ProcessEvent(chunk.Text.Length, time);
}
else if (chunk.Type == AIAnalyze.AIChunkType.Error)
{
errorSb.AppendLine(chunk.Text);
}
}
if (thinkSb.Length > 0)
{
AppendToParagraph(_currentContent?.Think, thinkSb.ToString(), ThinkStyle);
}
if (contentSb.Length > 0)
{
if (_thinkBlockWasExpanded)
{
EndThinkingBlock();
}
AppendToParagraph(_currentContent?.Content, contentSb.ToString(), ContentStyle);
}
if (errorSb.Length > 0)
{
AddErrorBlock(errorSb.ToString());
}
}
private void AutoScroll()
{
const double BottomThreshold = 20.0;
var isNearBottom = false;
if (_scrollViewer is not null)
{
isNearBottom = _scrollViewer.VerticalOffset + _scrollViewer.ViewportHeight
>= _scrollViewer.ExtentHeight - BottomThreshold;
}
if (isNearBottom)
{
Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() =>
{
_scrollViewer?.ScrollToEnd();
}));
}
}
// ---- Token 显示 ----
private void UpdateTokenDisplay(AIAnalyze.Result result)
{
_totalPromptTokens += result.PromptTokens ?? 0;
_totalCompletionTokens += result.CompletionTokens ?? 0;
var total = _totalPromptTokens + _totalCompletionTokens;
_currentTokensText.Text =
$"上次请求 Token:输入 {FormatNumber(result.PromptTokens, false)}"
+ $" 输出 {FormatNumber(result.CompletionTokens, false)}";
_conversationTokensText.Text =
$"累计 Token:输入 {FormatNumber(_totalPromptTokens, false)}"
+ $" 输出 {FormatNumber(_totalCompletionTokens, false)}"
+ $" 总计 {FormatNumber(total, false)}";
}
// ---- 回滚与错误显示 ----
private void RollbackToLastSavePoint()
{
FinishCurrentContent();
while (_document.Blocks.Count > _blockCountBeforeSegment)
{
_document.Blocks.Remove(_document.Blocks.LastBlock);
}
_updateCurrentThinkingSection = null;
_thinkBlockWasExpanded = false;
_currentContent = null;
}
private void AddErrorBlock(string message)
{
AutoScroll();
FinishCurrentContent();
var paragraph = new Paragraph(new Run(message)
{
Foreground = ErrorStyle.Foreground,
FontWeight = FontWeights.Bold
});
_document.Blocks.Add(paragraph);
}
// ---- 按钮动作 ----
private void OnCancelClick(object sender, RoutedEventArgs e)
{
_internalCts?.Cancel();
}
private async void OnRetryClick(object sender, RoutedEventArgs e)
{
if (!_canRetry || _analyzer is null || _eventCounts is null)
{
MessageBox.Show("无法重试:没有可用的分析器或事件计数。", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
if (_externalCancelToken is not { } externalCancelToken)
{
MessageBox.Show("无法重试:外部取消令牌不可用。", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
if (_analyzer.LastSuccessfulState.CurrentSegment < 0)
{
_canRetry = false;
UpdateButtons();
MessageBox.Show("请重新点击“AI分析”按钮", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
// 回滚到上一个成功段落
RollbackToLastSavePoint();
// 移除可能已有的错误信息
if (_document.Blocks.LastBlock is Paragraph lastPara
&& lastPara.Inlines.FirstInline is Run run
&& run.Text.StartsWith("[错误]"))
{
_document.Blocks.Remove(lastPara);
}
_isFailed = false;
_canRetry = false;
_isRunning = true;
UpdateButtons();
StartUiTimer();
// 继续分段循环
try
{
using var onExit = new OnDisposeAction(() =>
{
FinishCurrentContent();
// invoke UI update one last time to ensure final state is reflected
OnUiTimerTick(null, EventArgs.Empty);
// ensure UI timer is stopped before catch or finally blocks
StopUiTimer();
});
// 创建新的 CancellationTokenSource,允许新的取消
_internalCts = new CancellationTokenSource();
_linkedCts = CancellationTokenSource.CreateLinkedTokenSource(externalCancelToken, _internalCts.Token);
// 注意:此时 _analyzer.CurrentSegment 停留在失败的那一段
// 需要继续 ProcessSegmentsAsync,但需要将现有的 result.State 作为起点
await ProcessSegmentsAsync(_analyzer.LastSuccessfulState, _eventCounts);
// 总结
await ProcessSummaryAsync(_eventCounts);
}
catch (OperationCanceledException)
{
RollbackToLastSavePoint();
AddErrorBlock("[已取消]");
_isFailed = true;
_canRetry = (_analyzer != null);
}
catch (Exception ex)
{
RollbackToLastSavePoint();
AddErrorBlock($"[错误] {ex.Message}");
_isFailed = true;
_canRetry = (_analyzer != null);
}
finally
{
_isRunning = false;
StopUiTimer();
UpdateButtons();
_linkedCts?.Dispose();
_linkedCts = null;
_internalCts?.Dispose();
_internalCts = null;
}
}
private void OnAbortClick(object sender, RoutedEventArgs e)
{
_internalCts?.Cancel();
Reset();
}
// ---- 按钮状态管理 ----
private void UpdateButtons()
{
_cancelButton.Visibility = _isRunning ? Visibility.Visible : Visibility.Collapsed;
_retryButton.Visibility = _isFailed && _canRetry ? Visibility.Visible : Visibility.Collapsed;
_abortButton.Visibility = _isFailed || _isRunning ? Visibility.Visible : Visibility.Collapsed;
}
private static string FormatNumber(long? n, bool binary, string unit = "")
{
if (n is null)
{
return "N/A";
}
var k = binary ? 1024.0 : 1000.0;
var m = k * k;
if (n < k)
{
return $"{n}{unit}";
}
if (n < m)
{
return $"{n / k:0.#}{(binary ? "Ki" : "K")}{unit}";
}
return $"{n / m:0.#}{(binary ? "Mi" : "M")}{unit}";
}
}
}