Files
AnotherReplayReader/AIChatPanel.xaml.cs
T
2026-08-24 04:23:43 +02:00

1836 lines
71 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.Linq;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
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 bool _nextThinkingIsContinuation;
// 进度计时
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();
private readonly Queue<Action> _pendingUiActions = new();
private bool _isFlushingPendingUiActions;
// 阶段化 UI
private readonly Dictionary<string, Section> _stageSections = new();
private string? _currentStage;
private Section? _currentVersionSection;
private Paragraph? _currentVersionBody;
private string? _currentVersionTitle;
private UpdateCollapsibleSection? _currentVersionUpdate;
private Action<Block>? _currentVersionAddBlock;
private bool _currentVersionBodyAdded;
// Token 累计
private int _totalPromptTokens;
private int _totalCompletionTokens;
private int _totalReasoningTokens;
// ---------- 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();
_stageSections.Clear();
_currentStage = null;
_currentVersionSection = null;
_currentVersionBody = null;
_currentVersionTitle = null;
_currentVersionUpdate = null;
_currentVersionAddBlock = null;
_currentVersionBodyAdded = false;
_pendingUiActions.Clear();
_isFlushingPendingUiActions = false;
_updateCurrentThinkingSection = null;
_thinkBlockWasExpanded = false;
_currentContent = null;
_lastContentParagraph = null;
_suppressDisplay = false;
_nextThinkingIsContinuation = 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;
_totalReasoningTokens = 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());
BeginStage("准备");
// ---- 机械分段(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)} token0 = 不支持长录像)\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 = "正在生成总览...";
BeginStage("总览");
AppendLog(
"总览用户消息",
AIAnalyze.BuildOverviewUserPrompt(_slices),
true);
StartVersion("版本 1:总览正文", collapsedByDefault: false);
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);
ReportReasoningGuardResult(overviewResult);
_overview = OverviewParser.Parse(overviewResult.Response);
_overviewNarrative = _overview.Narrative;
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} 段";
BeginStage($"第{segmentIndex}段");
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);
StartVersion("版本 1:正文", collapsedByDefault: false);
// 回查循环(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);
ReportReasoningGuardResult(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;
var backqueryPrompt = AIAnalyze.BuildBackqueryUserPrompt(
string.Join("\n\n", pendingTexts));
segmentMessages = segmentMessages
.Add(new AIAnalyze.ChatMessage("assistant", segmentResponse))
.Add(new AIAnalyze.ChatMessage(
"user", backqueryPrompt));
AppendLog(
$"第{segmentIndex}段回查(第 {backqueryCount} 次)",
$"已提供 {pendingTexts.Count} 个区间,继续分析。\n\n用户消息:\n" + backqueryPrompt,
true);
}
FinishCurrentContent();
var validationResult = AIAnalysisValidation.ValidateMachineReadableClaims(
segmentResponse, _factIndex, playerNameToIndex, structuredKnowledge);
AppendMachineJsonAndValidation(segmentResponse, validationResult);
if (validationResult.HasIssues)
{
AppendLog(
$"第{segmentIndex}段机器可读声明检查",
AIAnalysisValidation.FormatIssues(validationResult.Issues),
false);
}
// 隐藏修订 passM6):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);
var relevantFacts = RelevantFactsFormatter.Format(validationResult.Claims, _factIndex!);
var revisionPrompt = AIAnalyze.BuildRevisionUserPrompt(
segmentResponse,
AIAnalysisValidation.FormatIssues(validationResult.Issues),
string.IsNullOrWhiteSpace(relevantFacts) ? "(无额外事实)" : relevantFacts);
AppendLog(
$"第{segmentIndex}段修订",
$"验证器发现 {issueCount} 个需要修正/降级的问题,正在请求 AI 修正...\n\n用户消息:\n"
+ revisionPrompt,
true);
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);
ReportReasoningGuardResult(revisionResult);
if (string.IsNullOrWhiteSpace(revisionResult.Response))
{
AppendLog("修订失败", "修订输出为空,保留原分析。", false);
}
else
{
finalResponse = revisionResult.Response;
finalValidation = AIAnalysisValidation.ValidateMachineReadableClaims(
revisionResult.Response, _factIndex, playerNameToIndex, structuredKnowledge);
CollapseCurrentVersion();
StartVersion("版本 2:修订后正文", collapsedByDefault: false);
AppendDirectText(finalResponse);
AppendMachineJsonAndValidation(finalResponse, finalValidation);
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 = "正在生成总结...";
BeginStage("总结");
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);
StartVersion("版本 1:总结正文", collapsedByDefault: false);
var result = await _analyzer!.CompleteAsync(
messages,
requestContext,
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(result);
ReportReasoningGuardResult(result);
// 总结轮回查:允许模型请求一次远处原始区间,作为同一会话的追加输入。
if (_replayData is null)
{
return;
}
var backqueries = BackqueryParser.Parse(result.Response);
if (backqueries.IsEmpty)
{
AppendLog("总结完成", "已生成最终总结。", false);
return;
}
const int maxSummaryBackqueries = 3;
var pendingTexts = new List<string>();
foreach (var (start, end) in backqueries)
{
if (pendingTexts.Count >= maxSummaryBackqueries)
{
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)
{
return;
}
_phaseText.Text = "正在根据总结回查补充信息...";
var backqueryPrompt = AIAnalyze.BuildBackqueryUserPrompt(
string.Join("\n\n", pendingTexts));
var backqueryMessages = messages
.Add(new AIAnalyze.ChatMessage("assistant", result.Response))
.Add(new AIAnalyze.ChatMessage("user", backqueryPrompt));
AppendLog(
"总结回查",
$"已提供 {pendingTexts.Count} 个区间,正在生成最终总结。\n\n用户消息:\n"
+ backqueryPrompt,
true);
CheckAndLogContextUsage(backqueryMessages, requestContext);
CollapseCurrentVersion();
StartVersion("版本 2:最终总结", collapsedByDefault: false);
var finalSummary = await _analyzer!.CompleteAsync(
backqueryMessages,
requestContext,
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(finalSummary);
ReportReasoningGuardResult(finalSummary);
AppendLog("总结完成", "已根据回查区间补充最终总结。", false);
}
// ---- 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 ?? string.Empty);
total += AiContextBudget.EstimateTokens(
message.ReasoningContent ?? string.Empty);
}
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 ReportReasoningGuardResult(AIAnalyze.Result result)
{
if (string.IsNullOrWhiteSpace(result.ReasoningContinuationError))
{
return;
}
AppendLog("推理保护警告", result.ReasoningContinuationError, false, _currentStage);
}
private void StartThinkingBlock()
{
_thinkingStartTime = DateTime.Now;
var stage = _currentStage ?? "分析";
var label = _nextThinkingIsContinuation ? "AI 续写中..." : "AI 思考中...";
_nextThinkingIsContinuation = false;
var parent = _currentVersionAddBlock is null
? GetStageBlocks(_currentStage)
: null;
var (section, content, update, _) = CreateCollapsibleSection(
$"💭 [{stage}] {label}",
collapsedByDefault: false,
parent);
if (_currentVersionAddBlock is { } addBlock)
{
addBlock(section);
}
_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} 分钟";
var stage = _currentStage ?? "分析";
// 自动折叠:移除段落,更新按钮文字
_updateCurrentThinkingSection(
$"💭 [{stage}] AI 已思考完毕(用时 {timeText}",
true);
_updateCurrentThinkingSection = null;
}
private void StartContentParagraph()
{
EnsureVersionBodyAdded();
var body = _currentVersionBody ?? new Paragraph();
_currentContent = (_currentContent?.Think, Content: body);
_lastContentParagraph = _currentContent.Value.Content;
if (_currentVersionBody is null)
{
GetStageBlocks(_currentStage).Add(body);
}
}
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 BeginStage(string stage)
{
if (_stageSections.TryGetValue(stage, out var existing))
{
_currentStage = stage;
return existing;
}
var section = new Section();
var header = new Paragraph(new Run($"📌 {stage}")
{
Foreground = CollapsibleSectionHeaderStyle.Foreground,
FontSize = DefaultFontSize,
FontWeight = FontWeights.Bold
});
section.Blocks.Add(header);
_document.Blocks.Add(section);
_stageSections[stage] = section;
_currentStage = stage;
return section;
}
private BlockCollection GetStageBlocks(string? stage = null)
{
stage ??= _currentStage;
return stage != null && _stageSections.TryGetValue(stage, out var section)
? section.Blocks
: _document.Blocks;
}
private void StartVersion(string title, bool collapsedByDefault)
{
var (section, content, update, addBlock) =
CreateCollapsibleSection(
title,
collapsedByDefault,
GetStageBlocks(_currentStage));
_currentVersionSection = section;
_currentVersionBody = content;
_currentVersionTitle = title;
_currentVersionUpdate = update;
_currentVersionAddBlock = addBlock;
_currentVersionBodyAdded = false;
_currentContent = null;
_lastContentParagraph = null;
// 正文段落先不加入文档;思考块/保护日志会按时间线先追加,
// 真正开始输出正文时才把正文追加到它们之后。
section.Blocks.Remove(content);
}
private void CollapseCurrentVersion()
{
FinishCurrentContent();
if (_currentVersionUpdate is { } update)
{
update(_currentVersionTitle ?? "版本", true);
}
_currentVersionSection = null;
_currentVersionBody = null;
_currentVersionTitle = null;
_currentVersionUpdate = null;
_currentVersionAddBlock = null;
_currentVersionBodyAdded = false;
}
private void EnsureVersionBodyAdded()
{
if (_currentVersionBody is { } body
&& !_currentVersionBodyAdded
&& _currentVersionAddBlock is { } addBlock)
{
addBlock(body);
_currentVersionBodyAdded = true;
}
}
private void AppendDirectText(string text)
{
EnsureVersionBodyAdded();
AppendToParagraph(_currentVersionBody, text, ContentStyle);
}
private void ReplaceVersionBodyText(string text)
{
if (_currentVersionBody is not { } body)
{
return;
}
body.Inlines.Clear();
AppendTextToParagraph(body, text, ContentStyle);
}
private static void AppendTextToParagraph(
Paragraph paragraph,
string text,
ParagraphStyle style)
{
foreach (var rawLine in text.Split('\n'))
{
var line = rawLine.TrimEnd('\r');
paragraph.Inlines.Add(new Run(line)
{
Foreground = style.Foreground,
FontSize = style.FontSize
});
paragraph.Inlines.Add(new LineBreak());
}
}
private void AppendMachineJsonAndValidation(
string response,
AIValidationResult validation)
{
if (_currentVersionAddBlock is not { } addBlock)
{
return;
}
var json = ExtractMachineReadableJson(response);
if (!string.IsNullOrWhiteSpace(json))
{
ReplaceVersionBodyText(StripMachineReadableSection(response));
var (jsonSection, jsonContent, _, _) =
CreateCollapsibleSection("机器可读声明 JSON", true);
jsonContent.Inlines.Add(new Run(json)
{
Foreground = LogStyle.Foreground,
FontSize = DetailsFontSize
});
addBlock(jsonSection);
}
var validationTitle = validation.HasIssues
? $"验证结果:{validation.Issues.Length} 个问题"
: "验证结果:通过";
var validationDetails = validation.HasIssues
? AIAnalysisValidation.FormatIssues(validation.Issues)
: "机器可读声明验证通过。";
var (validationSection, validationContent, _, _) =
CreateCollapsibleSection(validationTitle, true);
validationContent.Inlines.Add(new Run(validationDetails)
{
Foreground = LogStyle.Foreground,
FontSize = DetailsFontSize
});
addBlock(validationSection);
}
private static string StripMachineReadableSection(string text)
{
var markerIndex = text.IndexOf(
"[机器可读声明]",
StringComparison.Ordinal);
if (markerIndex < 0)
{
return text;
}
var after = text.Substring(markerIndex);
var fenceStart = after.IndexOf("```", StringComparison.Ordinal);
if (fenceStart < 0)
{
return text.Substring(0, markerIndex).TrimEnd();
}
var fenceEnd = after.IndexOf(
"```",
fenceStart + 3,
StringComparison.Ordinal);
if (fenceEnd < 0)
{
return text.Substring(0, markerIndex).TrimEnd();
}
var removeEnd = markerIndex + fenceEnd + 3;
var before = text.Substring(0, markerIndex).TrimEnd();
var rest = text.Substring(removeEnd).TrimStart();
return rest.Length > 0 ? before + "\n\n" + rest : before;
}
private static string? ExtractMachineReadableJson(string text)
{
var markerIndex = text.IndexOf(
"[机器可读声明]",
StringComparison.Ordinal);
if (markerIndex < 0)
{
return null;
}
var after = text.Substring(markerIndex);
var fenceStart = after.IndexOf("```", StringComparison.Ordinal);
if (fenceStart < 0)
{
return null;
}
var fenceEnd = after.IndexOf(
"```",
fenceStart + 3,
StringComparison.Ordinal);
if (fenceEnd < 0)
{
return null;
}
var block = after.Substring(
fenceStart + 3,
fenceEnd - fenceStart - 3);
var jsonStart = block.IndexOf('{');
var jsonEnd = block.LastIndexOf('}');
if (jsonStart < 0 || jsonEnd <= jsonStart)
{
return null;
}
return block.Substring(jsonStart, jsonEnd - jsonStart + 1).Trim();
}
private void AppendReasoningDiagnostics(string payload)
{
try
{
using var doc = JsonDocument.Parse(payload);
var root = doc.RootElement;
var originalText = root.TryGetProperty("originalRequestJson", out var original)
&& original.ValueKind == JsonValueKind.String
? original.GetString()
: null;
var continuationText =
root.TryGetProperty("continuationRequestJson", out var continuation)
&& continuation.ValueKind == JsonValueKind.String
? continuation.GetString()
: null;
var summary = BuildReasoningDiagnosticSummary(
originalText,
continuationText);
if (!string.IsNullOrWhiteSpace(summary))
{
AppendLog("推理保护诊断", summary, true, _currentStage);
}
if (!string.IsNullOrWhiteSpace(continuationText))
{
AppendLog(
"完整续写请求 JSON",
PrettyPrintJson(continuationText!),
true,
_currentStage);
}
}
catch
{
AppendLog("推理保护诊断", payload, true, _currentStage);
}
}
private static string BuildReasoningDiagnosticSummary(
string? originalJson,
string? continuationJson)
{
var sb = new StringBuilder();
sb.AppendLine($"消息数:{GetMessageCount(originalJson)} → {GetMessageCount(continuationJson)}");
if (string.IsNullOrWhiteSpace(continuationJson))
{
return sb.ToString().TrimEnd();
}
try
{
using var doc = JsonDocument.Parse(continuationJson!);
if (!doc.RootElement.TryGetProperty("messages", out var messages)
|| messages.ValueKind != JsonValueKind.Array)
{
return sb.ToString().TrimEnd();
}
for (var i = messages.GetArrayLength() - 1; i >= 0; --i)
{
var message = messages[i];
if (!message.TryGetProperty("tool_calls", out var toolCalls)
|| toolCalls.ValueKind != JsonValueKind.Array
|| toolCalls.GetArrayLength() == 0)
{
continue;
}
var toolName = "unknown";
if (toolCalls[0].TryGetProperty("function", out var function)
&& function.TryGetProperty("name", out var name))
{
toolName = name.GetString() ?? toolName;
}
var reasoning = message.TryGetProperty("reasoning_content", out var reasoningProp)
? reasoningProp.GetString()
: null;
sb.AppendLine("新增 assistant 消息:");
sb.AppendLine($" - reasoning_content 长度:{reasoning?.Length ?? 0}");
if (!string.IsNullOrEmpty(reasoning))
{
var markerIndex = reasoning.IndexOf(
AiReasoningGuard.TruncationMarker,
StringComparison.Ordinal);
var tail = markerIndex >= 0
? reasoning.Substring(markerIndex)
: "(未找到截断标记)";
sb.AppendLine($" - 末尾追加:{Excerpt(tail, 160)}");
}
sb.AppendLine($" - tool_calls{toolName}");
break;
}
for (var i = messages.GetArrayLength() - 1; i >= 0; --i)
{
var message = messages[i];
if (!message.TryGetProperty("role", out var role)
|| role.GetString() != "tool")
{
continue;
}
var toolCallId = message.TryGetProperty("tool_call_id", out var id)
? id.GetString()
: "?";
var content = message.TryGetProperty("content", out var contentProp)
? contentProp.GetString()
: null;
sb.AppendLine("新增 tool 消息:");
sb.AppendLine($" - tool_call_id{toolCallId}");
sb.AppendLine($" - 指令开头:{Excerpt(content ?? "()", 160)}");
break;
}
}
catch
{
// 摘要解析失败时保留原始 JSON 回退。
}
return sb.ToString().TrimEnd();
}
private static int GetMessageCount(string? requestJson)
{
if (string.IsNullOrWhiteSpace(requestJson))
{
return 0;
}
try
{
using var doc = JsonDocument.Parse(requestJson!);
return doc.RootElement.TryGetProperty("messages", out var messages)
&& messages.ValueKind == JsonValueKind.Array
? messages.GetArrayLength()
: 0;
}
catch
{
return 0;
}
}
private static string PrettyPrintJson(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
return JsonSerializer.Serialize(
doc.RootElement,
new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
}
catch
{
return json;
}
}
private static string Excerpt(string text, int maxLength)
{
var normalized = text.Replace("\r", "").Replace("\n", " ");
return normalized.Length <= maxLength
? normalized
: normalized.Substring(0, maxLength) + "…";
}
private void FlushPendingUiActions()
{
_isFlushingPendingUiActions = true;
try
{
while (_pendingUiActions.Count > 0)
{
_pendingUiActions.Dequeue()();
}
}
finally
{
_isFlushingPendingUiActions = false;
}
}
private (Section Section, Paragraph Content, UpdateCollapsibleSection UpdateSection, Action<Block> AddBlock)
CreateCollapsibleSection(
string title,
bool collapsedByDefault,
BlockCollection? parent = null)
{
var currentTitle = title;
var currentlyCollapsed = collapsedByDefault;
var section = new Section();
var header = new Paragraph();
var content = new Paragraph();
var contentBlocks = new List<Block> { content };
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)
{
foreach (var block in contentBlocks)
{
section.Blocks.Remove(block);
}
}
else
{
foreach (var block in contentBlocks)
{
if (!section.Blocks.Contains(block))
{
section.Blocks.Add(block);
}
}
}
}
void AddBlock(Block block)
{
if (block == content)
{
// 正文段落应在思考/日志之后进入时间线;
// 但它作为版本的初始 content 已在列表中,需要移到末尾。
contentBlocks.Remove(block);
contentBlocks.Add(block);
}
else if (!contentBlocks.Contains(block))
{
contentBlocks.Add(block);
}
if (!currentlyCollapsed && !section.Blocks.Contains(block))
{
section.Blocks.Add(block);
}
}
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);
(parent ?? _document.Blocks).Add(section);
return (section, content, UpdateCollapsibleSection, AddBlock);
}
private void AppendLog(
string title,
string? details,
bool collapsed,
string? stage = null)
{
if (!_isFlushingPendingUiActions)
{
AutoScroll();
FinishCurrentContent();
}
title = $"📋 {title}";
var addBlock = _currentVersionAddBlock;
var parent = addBlock is null ? GetStageBlocks(stage) : null;
if (string.IsNullOrEmpty(details))
{
var paragraph = new Paragraph(new Run(title)
{
Foreground = LogStyle.Foreground,
});
if (addBlock is { } add)
{
add(paragraph);
}
else
{
parent!.Add(paragraph);
}
return;
}
var (logSection, logParagraph, _, _) = CreateCollapsibleSection(
title,
collapsed,
parent);
if (addBlock is { } addLog)
{
addLog(logSection);
}
foreach (var rawLine in details.Split('\n'))
{
var line = rawLine.TrimEnd('\r');
logParagraph.Inlines.Add(new Run(line)
{
Foreground = LogStyle.Foreground,
FontSize = LogStyle.FontSize
});
logParagraph.Inlines.Add(new LineBreak());
}
}
// ---- 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 _)) { }
_pendingUiActions.Clear();
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();
FlushPendingUiActions();
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))
{
if (data.Chunk.Type == AIAnalyze.AIChunkType.ReasoningGuard)
{
var text = data.Chunk.Text;
_nextThinkingIsContinuation = true;
_pendingUiActions.Enqueue(() =>
AppendLog("推理保护", text, false, _currentStage));
}
else if (data.Chunk.Type == AIAnalyze.AIChunkType.ReasoningGuardRequest)
{
var text = data.Chunk.Text;
_pendingUiActions.Enqueue(() =>
AppendReasoningDiagnostics(text));
}
else
{
_currentOutputChars += data.Chunk.Text.Length;
}
}
return;
}
var thinkSb = new StringBuilder();
var contentSb = new StringBuilder();
var errorSb = new StringBuilder();
var guardTriggered = false;
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);
}
else if (chunk.Type == AIAnalyze.AIChunkType.ReasoningGuard)
{
EndThinkingBlock();
_nextThinkingIsContinuation = true;
_pendingUiActions.Enqueue(() =>
AppendLog("推理保护", chunk.Text, false, _currentStage));
guardTriggered = true;
}
else if (chunk.Type == AIAnalyze.AIChunkType.ReasoningGuardRequest)
{
_pendingUiActions.Enqueue(() =>
AppendReasoningDiagnostics(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());
}
if (guardTriggered)
{
_currentContent = null;
}
}
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;
_totalReasoningTokens += result.ReasoningTokens ?? 0;
var total = _totalPromptTokens + _totalCompletionTokens;
_currentTokensText.Text =
$"上次请求 Token:输入 {FormatNumber(result.PromptTokens, false)}"
+ $" 输出 {FormatNumber(result.CompletionTokens, false)}"
+ (result.ReasoningTokens is { } reasoning ? $" 推理 {FormatNumber(reasoning, false)}" : "");
_conversationTokensText.Text =
$"累计 Token:输入 {FormatNumber(_totalPromptTokens, false)}"
+ $" 输出 {FormatNumber(_totalCompletionTokens, false)}"
+ (_totalReasoningTokens > 0 ? $" 推理 {FormatNumber(_totalReasoningTokens, 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}";
}
}
}