1219 lines
48 KiB
C#
1219 lines
48 KiB
C#
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.Linq;
|
||
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;
|
||
|
||
// v2 管线状态
|
||
private Replay? _replay;
|
||
private ImmutableSortedDictionary<int, Player> _players = ImmutableSortedDictionary<int, Player>.Empty;
|
||
private string? _systemPrompt;
|
||
private string? _replayData;
|
||
private ImmutableArray<EventSpan> _eventSpans = ImmutableArray<EventSpan>.Empty;
|
||
private ImmutableArray<ReplaySlice> _slices = ImmutableArray<ReplaySlice>.Empty;
|
||
private string? _digest;
|
||
private OverviewResult _overview = new(string.Empty, ImmutableArray<SegmentOverview>.Empty);
|
||
private string? _overviewNarrative;
|
||
private readonly List<string> _findings = new();
|
||
private readonly List<string> _segmentResponses = new();
|
||
private int _currentSegmentIndex;
|
||
|
||
// 分析状态
|
||
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 Paragraph? _lastContentParagraph;
|
||
private bool _suppressDisplay;
|
||
|
||
// 进度计时
|
||
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;
|
||
_replay = null;
|
||
_players = ImmutableSortedDictionary<int, Player>.Empty;
|
||
_systemPrompt = null;
|
||
_replayData = null;
|
||
_eventSpans = ImmutableArray<EventSpan>.Empty;
|
||
_slices = ImmutableArray<ReplaySlice>.Empty;
|
||
_digest = null;
|
||
_overview = new(string.Empty, ImmutableArray<SegmentOverview>.Empty);
|
||
_overviewNarrative = null;
|
||
_findings.Clear();
|
||
_segmentResponses.Clear();
|
||
_currentSegmentIndex = 0;
|
||
|
||
_document.Blocks.Clear();
|
||
_updateCurrentThinkingSection = null;
|
||
_thinkBlockWasExpanded = false;
|
||
_currentContent = null;
|
||
_lastContentParagraph = null;
|
||
_suppressDisplay = false;
|
||
|
||
_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,
|
||
ImmutableArray<EventSpan> eventSpans,
|
||
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;
|
||
_replay = replay;
|
||
_players = players;
|
||
_replayData = replayData;
|
||
_eventSpans = eventSpans;
|
||
|
||
FinishCurrentContent();
|
||
var requestContext = GetRequestContext();
|
||
_systemPrompt = AIAnalyze.GetSystemPrompt(replay, players, GetPromptSettings?.Invoke());
|
||
|
||
// ---- 机械分段(M2) ----
|
||
var model = requestContext.Model;
|
||
var budget = AiContextBudget.GetContextBudget(model);
|
||
var hardCap = model.ContextLength > 0
|
||
? (int)(model.ContextLength * AiContextBudget.HardUsageRatio)
|
||
: 128_000; // 上下文未知时的保守默认;仅用于“预算为 0 时的单 slice”判断。
|
||
var effectiveBudget = budget > 0 ? budget : 0;
|
||
var headroom = AiContextBudget.GetOutputHeadroom(requestContext.Provider, model);
|
||
var fixedOverhead = AiContextBudget.EstimateTokens(_systemPrompt)
|
||
+ 4_000 // digest
|
||
+ 2_000 // overview
|
||
+ 1_500 // instruction
|
||
+ 10_000 // findings reserve
|
||
+ headroom;
|
||
var logTokens = AiContextBudget.EstimateTokens(replayData);
|
||
int sliceBudget;
|
||
if (effectiveBudget <= 0)
|
||
{
|
||
// 没有上下文预算(128K 及以下或未知):只允许短录像整局作为一个 slice。
|
||
if (logTokens + fixedOverhead > hardCap)
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"模型没有足够的上下文预算({FormatNumber(budget, false)} token),日志约 {FormatNumber(logTokens, false)} token,"
|
||
+ $"加上固定开销后超过单 slice 上限 {FormatNumber(hardCap, false)} token。"
|
||
+ "请改用更长上下文的模型,或缩短操作记录。");
|
||
}
|
||
sliceBudget = Math.Max(logTokens, MechanicalSegmenter.MinSliceTokens);
|
||
AppendLog("上下文预算提示", "模型上下文较短,仅按单 slice 处理整局;长录像质量不保证。", false);
|
||
}
|
||
else
|
||
{
|
||
sliceBudget = Math.Max(effectiveBudget - fixedOverhead, MechanicalSegmenter.MinSliceTokens);
|
||
}
|
||
var (slices, sliceWarnings) = MechanicalSegmenter.Slice(
|
||
replayData, eventSpans, sliceBudget, sliceBudget / 12);
|
||
_slices = slices;
|
||
foreach (var warning in sliceWarnings)
|
||
{
|
||
AppendLog("分段警告", warning, false);
|
||
}
|
||
if (_slices.IsEmpty)
|
||
{
|
||
throw new InvalidOperationException("操作记录为空,无法分析。");
|
||
}
|
||
|
||
AppendLog(
|
||
"v2 管线准备",
|
||
$"上下文预算: {FormatNumber(budget, false)} token(0 = 不支持长录像)\r\n"
|
||
+ $"分段预算: {FormatNumber(sliceBudget, false)} token,实际切分 {_slices.Length} 段\r\n"
|
||
+ $"日志总量: {FormatNumber(logTokens, false)} token\r\n"
|
||
+ $"[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
|
||
false);
|
||
|
||
// ---- 对局摘要 ----
|
||
_digest = MatchDigestBuilder.Build(factIndex, players, replay.Mod, _slices, replayData);
|
||
AppendLog("对局摘要", _digest, true);
|
||
|
||
// ---- 总览轮 ----
|
||
_phaseText.Text = "正在生成总览...";
|
||
var overviewMessages = BuildSystemMessages(_systemPrompt, _digest, null)
|
||
.Add(new AIAnalyze.ChatMessage("user", AIAnalyze.BuildOverviewUserPrompt(_slices)));
|
||
CheckAndLogContextUsage(overviewMessages, requestContext);
|
||
AIAnalyze.Result overviewResult = default;
|
||
var overviewAttempt = 0;
|
||
do
|
||
{
|
||
overviewResult = await _analyzer.CompleteAsync(
|
||
overviewMessages,
|
||
requestContext,
|
||
OnChunk,
|
||
_linkedCts.Token);
|
||
UpdateTokenDisplay(overviewResult);
|
||
_overview = OverviewParser.Parse(overviewResult.Response);
|
||
_overviewNarrative = _overview.Narrative;
|
||
AppendLog(
|
||
overviewAttempt == 0 ? "整局总览" : "整局总览(重试)",
|
||
overviewResult.Response,
|
||
false);
|
||
overviewAttempt++;
|
||
}
|
||
while (_overview.Segments.IsEmpty && overviewAttempt < 2);
|
||
if (_overview.Segments.IsEmpty)
|
||
{
|
||
AppendLog("总览解析警告", "未解析到 [分段概述] 块,将直接使用机械分段。", false);
|
||
}
|
||
|
||
// ---- 分段分析循环 ----
|
||
_currentSegmentIndex = 0;
|
||
await ProcessSegmentsAsync(requestContext);
|
||
|
||
// ---- 总结 ----
|
||
await ProcessSummaryAsync(requestContext);
|
||
|
||
// 成功结束
|
||
_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;
|
||
}
|
||
}
|
||
|
||
// ---- 分析阶段(v2) ----
|
||
private async Task ProcessSegmentsAsync(AiRequestContext requestContext)
|
||
{
|
||
if (_linkedCts is null || _replayData is null || _systemPrompt is null || _digest is null || _replay is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var playerNameToIndex = AIAnalyze.PlayerNamesForAI(_replay.Mod, _players)
|
||
.ToDictionary(kv => kv.Value, kv => kv.Key);
|
||
var structuredKnowledge = StructuredKnowledge.GetForMod(
|
||
AIAnalyze.GetKnowledgeModName(_replay));
|
||
|
||
while (_currentSegmentIndex < _slices.Length)
|
||
{
|
||
_linkedCts.Token.ThrowIfCancellationRequested();
|
||
var slice = _slices[_currentSegmentIndex];
|
||
var segmentIndex = _currentSegmentIndex + 1;
|
||
var totalSegments = _slices.Length;
|
||
|
||
_phaseText.Text = $"正在分析第 {segmentIndex}/{totalSegments} 段";
|
||
|
||
FinishCurrentContent();
|
||
// 记录回滚点
|
||
_blockCountBeforeSegment = _document.Blocks.Count;
|
||
_lastContentParagraph = null;
|
||
|
||
var eventCount = _eventCounts?.Query(slice.Start, slice.End) ?? slice.EventCount;
|
||
var overviewEntry = _overview.Segments.FirstOrDefault(s => s.Index == segmentIndex);
|
||
var title = overviewEntry?.Title;
|
||
var sliceText = slice.GetText(_replayData);
|
||
var modelBudget = AiContextBudget.GetContextBudget(requestContext.Model);
|
||
var maxFindingsTokens = modelBudget > 0
|
||
? Math.Min(10_000, Math.Max(2_000, modelBudget / 10))
|
||
: 4_000;
|
||
var findingsText = LimitFindings(_findings, maxFindingsTokens);
|
||
var instruction = AIAnalyze.BuildSegmentUserPromptV2(
|
||
_currentSegmentIndex,
|
||
totalSegments,
|
||
slice,
|
||
eventCount,
|
||
title,
|
||
overviewEntry?.Description,
|
||
overviewEntry?.BackqueryHints);
|
||
|
||
var messages = BuildSystemMessages(_systemPrompt, _digest, _overviewNarrative)
|
||
.Add(new AIAnalyze.ChatMessage("user", sliceText));
|
||
if (!string.IsNullOrWhiteSpace(findingsText))
|
||
{
|
||
messages = messages.Add(new AIAnalyze.ChatMessage(
|
||
"user", "之前各段的已发现事实:\n" + findingsText));
|
||
}
|
||
messages = messages.Add(new AIAnalyze.ChatMessage("user", instruction));
|
||
|
||
AppendLog(
|
||
$"让 AI 分析第{segmentIndex}段...",
|
||
instruction + $"\r\n[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
|
||
true);
|
||
CheckAndLogContextUsage(messages, requestContext);
|
||
|
||
// 回查循环(M3):同一段会话内,模型可多次请求远处原始区间
|
||
const int maxBackqueriesPerSegment = 3;
|
||
var segmentMessages = messages;
|
||
var backqueryCount = 0;
|
||
string segmentResponse;
|
||
while (true)
|
||
{
|
||
// 回查会增长同一会话的消息列表,每次请求前都重新做预算检查。
|
||
CheckAndLogContextUsage(segmentMessages, requestContext);
|
||
var segmentResult = await _analyzer!.CompleteAsync(
|
||
segmentMessages,
|
||
requestContext,
|
||
OnChunk,
|
||
_linkedCts.Token);
|
||
UpdateTokenDisplay(segmentResult);
|
||
segmentResponse = segmentResult.Response;
|
||
|
||
if (backqueryCount >= maxBackqueriesPerSegment)
|
||
{
|
||
break;
|
||
}
|
||
|
||
var backqueries = BackqueryParser.Parse(segmentResponse);
|
||
var pendingTexts = new List<string>();
|
||
foreach (var (start, end) in backqueries)
|
||
{
|
||
if (backqueryCount + pendingTexts.Count >= maxBackqueriesPerSegment)
|
||
{
|
||
AppendLog("回查限制", "本段回查次数已达上限,剩余区间已忽略。", false);
|
||
break;
|
||
}
|
||
var (text, reason) = BackquerySliceExtractor.Extract(
|
||
_replayData, _eventSpans, start, end);
|
||
if (text is null)
|
||
{
|
||
AppendLog("回查失败", reason ?? "未知原因", false);
|
||
continue;
|
||
}
|
||
pendingTexts.Add(
|
||
$"[回查 {MatchDigestBuilder.FormatTime(start)}~{MatchDigestBuilder.FormatTime(end)}]\n" + text);
|
||
}
|
||
|
||
if (pendingTexts.Count == 0)
|
||
{
|
||
break;
|
||
}
|
||
backqueryCount += pendingTexts.Count;
|
||
segmentMessages = segmentMessages
|
||
.Add(new AIAnalyze.ChatMessage("assistant", segmentResponse))
|
||
.Add(new AIAnalyze.ChatMessage(
|
||
"user", AIAnalyze.BuildBackqueryUserPrompt(string.Join("\n\n", pendingTexts))));
|
||
AppendLog(
|
||
$"第{segmentIndex}段回查(第 {backqueryCount} 次)",
|
||
$"已提供 {pendingTexts.Count} 个区间,继续分析。",
|
||
true);
|
||
}
|
||
|
||
FinishCurrentContent();
|
||
var validationResult = AIAnalysisValidation.ValidateMachineReadableClaims(
|
||
segmentResponse, _factIndex, playerNameToIndex, structuredKnowledge);
|
||
if (validationResult.HasIssues)
|
||
{
|
||
AppendLog(
|
||
$"第{segmentIndex}段机器可读声明检查",
|
||
AIAnalysisValidation.FormatIssues(validationResult.Issues),
|
||
false);
|
||
}
|
||
|
||
// 隐藏修订 pass(M6):Contradiction 触发,最多 1 次;修订期间不流式显示
|
||
var finalResponse = segmentResponse;
|
||
var finalValidation = validationResult;
|
||
if (validationResult.RequiresRevision)
|
||
{
|
||
var issueCount = validationResult.Issues.Count(
|
||
i => i.Severity is AIValidationSeverity.Contradiction
|
||
or AIValidationSeverity.Fatal
|
||
or AIValidationSeverity.Warning
|
||
or AIValidationSeverity.WeakEvidence);
|
||
AppendLog(
|
||
$"第{segmentIndex}段修订",
|
||
$"验证器发现 {issueCount} 个需要修正/降级的问题,正在请求 AI 修正...",
|
||
false);
|
||
var relevantFacts = RelevantFactsFormatter.Format(validationResult.Claims, _factIndex!);
|
||
var revisionPrompt = AIAnalyze.BuildRevisionUserPrompt(
|
||
segmentResponse,
|
||
AIAnalysisValidation.FormatIssues(validationResult.Issues),
|
||
string.IsNullOrWhiteSpace(relevantFacts) ? "(无额外事实)" : relevantFacts);
|
||
var revisionMessages = segmentMessages
|
||
.Add(new AIAnalyze.ChatMessage("assistant", segmentResponse))
|
||
.Add(new AIAnalyze.ChatMessage("user", revisionPrompt));
|
||
CheckAndLogContextUsage(revisionMessages, requestContext);
|
||
|
||
_suppressDisplay = true;
|
||
AIAnalyze.Result revisionResult;
|
||
try
|
||
{
|
||
revisionResult = await _analyzer!.CompleteAsync(
|
||
revisionMessages,
|
||
requestContext,
|
||
OnChunk,
|
||
_linkedCts.Token);
|
||
}
|
||
finally
|
||
{
|
||
_suppressDisplay = false;
|
||
}
|
||
UpdateTokenDisplay(revisionResult);
|
||
|
||
if (string.IsNullOrWhiteSpace(revisionResult.Response))
|
||
{
|
||
AppendLog("修订失败", "修订输出为空,保留原分析。", false);
|
||
}
|
||
else
|
||
{
|
||
finalResponse = revisionResult.Response;
|
||
finalValidation = AIAnalysisValidation.ValidateMachineReadableClaims(
|
||
revisionResult.Response, _factIndex, playerNameToIndex, structuredKnowledge);
|
||
ReplaceSegmentContent(finalResponse);
|
||
AppendLog(
|
||
"修订完成",
|
||
"已采用修正后的分析。" + (finalValidation.HasIssues
|
||
? "\n仍存在的问题:\n" + AIAnalysisValidation.FormatIssues(finalValidation.Issues)
|
||
: string.Empty),
|
||
false);
|
||
}
|
||
}
|
||
FinishCurrentContent();
|
||
|
||
// 追加已发现事实
|
||
var entry = new StringBuilder();
|
||
var summary = ClaimFindingsFormatter.ExtractSummary(finalResponse);
|
||
if (!string.IsNullOrWhiteSpace(summary))
|
||
{
|
||
entry.AppendLine($"[小结] {summary}");
|
||
}
|
||
var claimsText = ClaimFindingsFormatter.Format(finalValidation.Claims);
|
||
if (!string.IsNullOrWhiteSpace(claimsText))
|
||
{
|
||
entry.AppendLine(claimsText);
|
||
}
|
||
if (entry.Length > 0)
|
||
{
|
||
_findings.Add(entry.ToString().TrimEnd());
|
||
}
|
||
|
||
_segmentResponses.Add(finalResponse);
|
||
_lastSuccessfulSegment = _currentSegmentIndex;
|
||
_currentSegmentIndex++;
|
||
AppendLog($"第{segmentIndex}段完成。", null, true);
|
||
}
|
||
}
|
||
|
||
private async Task ProcessSummaryAsync(AiRequestContext requestContext)
|
||
{
|
||
if (_linkedCts is null || _systemPrompt is null || _digest is null)
|
||
{
|
||
return;
|
||
}
|
||
_linkedCts.Token.ThrowIfCancellationRequested();
|
||
|
||
_phaseText.Text = "正在生成总结...";
|
||
FinishCurrentContent();
|
||
_blockCountBeforeSegment = _document.Blocks.Count;
|
||
|
||
var totalEvents = _eventCounts?.GetTotal() ?? 0;
|
||
var analyses = string.Join("\n\n", _segmentResponses);
|
||
var budget = AiContextBudget.GetContextBudget(requestContext.Model);
|
||
var maxAnalysesTokens = budget > 0 ? Math.Max(20_000, budget / 3) : 40_000;
|
||
var truncated = TruncateByTokens(analyses, maxAnalysesTokens);
|
||
if (!ReferenceEquals(truncated, analyses))
|
||
{
|
||
AppendLog(
|
||
"总结输入警告",
|
||
$"各段分析过长,已截断到约 {FormatNumber(maxAnalysesTokens, false)} token。",
|
||
false);
|
||
}
|
||
|
||
var finalPrompt = AIAnalyze.BuildSummaryUserPromptV2(totalEvents);
|
||
var findingsText = string.Join("\n\n", _findings);
|
||
var messages = BuildSystemMessages(_systemPrompt, _digest, _overviewNarrative)
|
||
.Add(new AIAnalyze.ChatMessage("user", "各分段的推理分析:\n" + truncated));
|
||
if (!string.IsNullOrWhiteSpace(findingsText))
|
||
{
|
||
messages = messages.Add(new AIAnalyze.ChatMessage(
|
||
"user", "各分段的已发现事实:\n" + findingsText));
|
||
}
|
||
messages = messages.Add(new AIAnalyze.ChatMessage("user", finalPrompt));
|
||
|
||
AppendLog(
|
||
"让 AI 生成总结...",
|
||
finalPrompt + $"\r\n[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
|
||
true);
|
||
CheckAndLogContextUsage(messages, requestContext);
|
||
var result = await _analyzer!.CompleteAsync(
|
||
messages,
|
||
requestContext,
|
||
OnChunk,
|
||
_linkedCts.Token);
|
||
|
||
UpdateTokenDisplay(result);
|
||
}
|
||
|
||
// ---- v2 管线辅助 ----
|
||
private static ImmutableList<AIAnalyze.ChatMessage> BuildSystemMessages(
|
||
string systemPrompt,
|
||
string digest,
|
||
string? overviewNarrative)
|
||
{
|
||
var content = systemPrompt + "\n\n# 对局摘要\n" + digest;
|
||
if (!string.IsNullOrWhiteSpace(overviewNarrative))
|
||
{
|
||
content += "\n\n# 整局总览\n" + overviewNarrative;
|
||
}
|
||
return ImmutableList<AIAnalyze.ChatMessage>.Empty
|
||
.Add(new AIAnalyze.ChatMessage("system", content));
|
||
}
|
||
|
||
private void CheckAndLogContextUsage(
|
||
ImmutableList<AIAnalyze.ChatMessage> messages,
|
||
AiRequestContext requestContext)
|
||
{
|
||
var total = 0;
|
||
foreach (var message in messages)
|
||
{
|
||
total += AiContextBudget.EstimateTokens(message.Content);
|
||
}
|
||
var check = AiContextBudget.CheckRequestUsage(
|
||
total, requestContext.Provider, requestContext.Model);
|
||
if (check.Block)
|
||
{
|
||
throw new InvalidOperationException(check.Message);
|
||
}
|
||
if (!check.IsOk)
|
||
{
|
||
AppendLog("上下文预算警告", check.Message, false);
|
||
}
|
||
}
|
||
|
||
private static string TruncateByTokens(string text, int maxTokens)
|
||
{
|
||
if (AiContextBudget.EstimateTokens(text) <= maxTokens)
|
||
{
|
||
return text;
|
||
}
|
||
// 中文约 1 token/字,取保守系数 1.1 字符/token
|
||
var chars = Math.Min(text.Length, (int)(maxTokens * 1.1));
|
||
return text.Substring(0, chars) + "\n…(已截断)";
|
||
}
|
||
|
||
/// <summary>已发现事实按 token 预算保留最近若干条,避免跨段事实无限增长。</summary>
|
||
private static string LimitFindings(IReadOnlyList<string> findings, int maxTokens)
|
||
{
|
||
if (findings.Count == 0)
|
||
{
|
||
return string.Empty;
|
||
}
|
||
var selected = new List<string>();
|
||
var totalTokens = 0;
|
||
for (var i = findings.Count - 1; i >= 0; --i)
|
||
{
|
||
var entry = findings[i];
|
||
var tokens = AiContextBudget.EstimateTokens(entry);
|
||
if (selected.Count > 0 && totalTokens + tokens > maxTokens)
|
||
{
|
||
break;
|
||
}
|
||
selected.Insert(0, entry);
|
||
totalTokens += tokens;
|
||
}
|
||
return string.Join("\n\n", selected);
|
||
}
|
||
|
||
// ---- 块追加与折叠 ----
|
||
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());
|
||
_lastContentParagraph = _currentContent.Value.Content;
|
||
_document.Blocks.Add(_currentContent.Value.Content);
|
||
}
|
||
|
||
private void ReplaceSegmentContent(string text)
|
||
{
|
||
FinishCurrentContent();
|
||
// 移除本段已显示的全部块(草稿/回查中间内容),只保留最终修正版
|
||
while (_document.Blocks.Count > _blockCountBeforeSegment)
|
||
{
|
||
_document.Blocks.Remove(_document.Blocks.LastBlock);
|
||
}
|
||
_currentContent = null;
|
||
_lastContentParagraph = null;
|
||
StartContentParagraph();
|
||
_lastContentParagraph!.Inlines.Add(new Run(text)
|
||
{
|
||
Foreground = ContentStyle.Foreground,
|
||
FontSize = ContentStyle.FontSize
|
||
});
|
||
}
|
||
|
||
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();
|
||
|
||
// 修订 pass 期间:不显示中间内容,只统计字符数
|
||
if (_suppressDisplay)
|
||
{
|
||
while (_chunkQueue.TryDequeue(out var data))
|
||
{
|
||
_currentOutputChars += data.Chunk.Text.Length;
|
||
}
|
||
return;
|
||
}
|
||
|
||
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 (_overviewNarrative is null)
|
||
{
|
||
_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);
|
||
|
||
// 注意:此时 _currentSegmentIndex 停留在失败的那一段
|
||
// _slices/_findings/_overview 保留,从失败段继续
|
||
if (GetRequestContext() is not { } retryContext)
|
||
{
|
||
throw new InvalidOperationException("未配置 AI 请求上下文,无法重试。");
|
||
}
|
||
await ProcessSegmentsAsync(retryContext);
|
||
|
||
// 总结
|
||
await ProcessSummaryAsync(retryContext);
|
||
}
|
||
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}";
|
||
}
|
||
}
|
||
}
|