Files
AnotherReplayReader/AIChatPanel.xaml.cs
T
2026-07-07 11:03:44 +02:00

848 lines
31 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ReplayFactIndex? _factIndex;
// 分析状态
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; }
public Func<AiPromptSettings>? GetPromptSettings { 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;
_factIndex = 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,
ReplayFactIndex factIndex,
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;
_factIndex = factIndex;
FinishCurrentContent();
var requestContext = GetRequestContext();
var systemPrompt = AIAnalyze.GetSystemPrompt(replay, players, GetPromptSettings?.Invoke());
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);
var validationResult = AIAnalysisValidation.ValidateMachineReadableClaims(segmentResult.Response, _factIndex);
if (validationResult.HasIssues)
{
AppendLog(
$"第{segmentIndex}段机器可读声明检查",
AIAnalysisValidation.FormatIssues(validationResult.Issues),
false);
}
_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}";
}
}
}