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.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 _players = ImmutableSortedDictionary.Empty; private string? _systemPrompt; private string? _replayData; private ImmutableArray _eventSpans = ImmutableArray.Empty; private ImmutableArray _slices = ImmutableArray.Empty; private string? _digest; private OverviewResult _overview = new(string.Empty, ImmutableArray.Empty); private string? _overviewNarrative; private readonly List _findings = new(); private readonly List _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 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 _pendingUiActions = new(); private bool _isFlushingPendingUiActions; // 阶段化 UI private readonly Dictionary _stageSections = new(); private string? _currentStage; private Section? _currentVersionSection; private Paragraph? _currentVersionBody; private string? _currentVersionTitle; private UpdateCollapsibleSection? _currentVersionUpdate; private Action? _currentVersionAddBlock; private bool _currentVersionBodyAdded; // Token 累计 private int _totalPromptTokens; private int _totalCompletionTokens; private int _totalReasoningTokens; // ---------- properties ---------- // 外部注入:每次请求前调用获取最新配置 // 委托类型变更 public Func? GetRequestContext { get; set; } public Func? 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.Empty; _systemPrompt = null; _replayData = null; _eventSpans = ImmutableArray.Empty; _slices = ImmutableArray.Empty; _digest = null; _overview = new(string.Empty, ImmutableArray.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; _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 players, string replayData, TimeIndexedPrefixSums eventCounts, ReplayFactIndex factIndex, ImmutableArray 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)} 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 = "正在生成总览..."; 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); _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)); const int maxBackqueriesPerSegment = 3; 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 windows = FocusPlanner.Plan(slice, _eventSpans); if (windows.IsEmpty) { windows = ImmutableArray.Create( new FocusWindow(0, slice.Start, slice.End, slice.EventCount, slice.EstimatedTokens)); } // 本段已发现事实:段内窗口之间的累积摘要(窗口内也会看到之前的段/窗口发现)。 var segmentFindings = new List(); for (var windowIndex = 0; windowIndex < windows.Length; ++windowIndex) { _linkedCts.Token.ThrowIfCancellationRequested(); var window = windows[windowIndex]; _phaseText.Text = $"正在分析第 {segmentIndex}/{totalSegments} 段(窗口 {windowIndex + 1}/{windows.Length})"; FinishCurrentContent(); _lastContentParagraph = null; // 段内已发现事实:窗口之间共享“已发现事实”,但只保留本段窗口已发现的。 var combinedFindings = new List(_findings); combinedFindings.AddRange(segmentFindings); var findingsText = LimitFindings(combinedFindings, maxFindingsTokens); var windowEventCount = _eventCounts?.Query(window.Start, window.End) ?? window.EventCount; var instruction = AIAnalyze.BuildFocusWindowUserPromptV2( _currentSegmentIndex, totalSegments, slice, windowIndex, windows.Length, window, eventCount, windowEventCount, 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}段窗口 {windowIndex + 1}/{windows.Length}...", instruction + $"\r\n[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]", true); CheckAndLogContextUsage(messages, requestContext); StartVersion( $"窗口 {windowIndex + 1}/{windows.Length}:正文", collapsedByDefault: false); // 回查循环(M3):同一窗口会话内,模型可多次请求远处原始区间 var windowMessages = messages; var backqueryCount = 0; string windowResponse; while (true) { // 回查会增长同一会话的消息列表,每次请求前都重新做预算检查。 CheckAndLogContextUsage(windowMessages, requestContext); var windowResult = await _analyzer!.CompleteAsync( windowMessages, requestContext, OnChunk, _linkedCts.Token); UpdateTokenDisplay(windowResult); windowResponse = windowResult.Response; if (backqueryCount >= maxBackqueriesPerSegment) { break; } var backqueries = BackqueryParser.Parse(windowResponse); var pendingTexts = new List(); 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)); windowMessages = windowMessages .Add(new AIAnalyze.ChatMessage("assistant", windowResponse)) .Add(new AIAnalyze.ChatMessage( "user", backqueryPrompt)); AppendLog( $"第{segmentIndex}段窗口 {windowIndex + 1} 回查(第 {backqueryCount} 次)", $"已提供 {pendingTexts.Count} 个区间,继续分析。\n\n用户消息:\n" + backqueryPrompt, true); } FinishCurrentContent(); var validationResult = AIAnalysisValidation.ValidateMachineReadableClaims( windowResponse, _factIndex, playerNameToIndex, structuredKnowledge); AppendMachineJsonAndValidation(windowResponse, validationResult); if (validationResult.HasIssues) { AppendLog( $"第{segmentIndex}段窗口 {windowIndex + 1} 机器可读声明检查", AIAnalysisValidation.FormatIssues(validationResult.Issues), false); } // 隐藏修订 pass(M6):Contradiction 触发,最多 1 次; // 修订草稿按时间顺序流式显示,完成后折叠草稿并默认展开最终正文。 var finalResponse = windowResponse; 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( windowResponse, AIAnalysisValidation.FormatIssues(validationResult.Issues), string.IsNullOrWhiteSpace(relevantFacts) ? "(无额外事实)" : relevantFacts); AppendLog( $"第{segmentIndex}段窗口 {windowIndex + 1} 修订", $"验证器发现 {issueCount} 个需要修正/降级的问题,正在请求 AI 修正...\n\n用户消息:\n" + revisionPrompt, true); var revisionMessages = windowMessages .Add(new AIAnalyze.ChatMessage("assistant", windowResponse)) .Add(new AIAnalyze.ChatMessage("user", revisionPrompt)); CheckAndLogContextUsage(revisionMessages, requestContext); FinishCurrentContent(); var previousVersionTitle = _currentVersionTitle; var previousVersionUpdate = _currentVersionUpdate; StartVersion("版本 2:修订草稿", collapsedByDefault: false); var revisionResult = await _analyzer!.CompleteAsync( revisionMessages, requestContext, OnChunk, _linkedCts.Token); UpdateTokenDisplay(revisionResult); if (string.IsNullOrWhiteSpace(revisionResult.Response)) { CollapseCurrentVersion(); AppendLog("修订失败", "修订输出为空,保留原分析。", false); } else { finalResponse = revisionResult.Response; finalValidation = AIAnalysisValidation.ValidateMachineReadableClaims( revisionResult.Response, _factIndex, playerNameToIndex, structuredKnowledge); CollapseCurrentVersion(); previousVersionUpdate?.Invoke( previousVersionTitle ?? "版本 1:正文", true); StartVersion("版本 3:修订后正文", collapsedByDefault: false); AppendDirectText(finalResponse); AppendMachineJsonAndValidation(finalResponse, finalValidation); AppendLog( "修订完成", "已采用修正后的分析。" + (finalValidation.HasIssues ? "\n仍存在的问题:\n" + AIAnalysisValidation.FormatIssues(finalValidation.Issues) : string.Empty), false); } } FinishCurrentContent(); // 追加本窗口已发现事实(同时进入全局 _findings,供后续段使用) 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) { var entryText = entry.ToString().TrimEnd(); segmentFindings.Add(entryText); _findings.Add(entryText); } _segmentResponses.Add(finalResponse); AppendLog( $"第{segmentIndex}段窗口 {windowIndex + 1}/{windows.Length} 完成。", null, true); } _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); // 总结轮回查:允许模型请求一次远处原始区间,作为同一会话的追加输入。 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(); 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); AppendLog("总结完成", "已根据回查区间补充最终总结。", false); } // ---- v2 管线辅助 ---- private static ImmutableList 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.Empty .Add(new AIAnalyze.ChatMessage("system", content)); } private void CheckAndLogContextUsage( ImmutableList 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…(已截断)"; } /// 已发现事实按 token 预算保留最近若干条,避免跨段事实无限增长。 private static string LimitFindings(IReadOnlyList findings, int maxTokens) { if (findings.Count == 0) { return string.Empty; } var selected = new List(); 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 stage = _currentStage ?? "分析"; var label = "AI 思考中..."; 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 FlushPendingUiActions() { _isFlushingPendingUiActions = true; try { while (_pendingUiActions.Count > 0) { _pendingUiActions.Dequeue()(); } } finally { _isFlushingPendingUiActions = false; } } private (Section Section, Paragraph Content, UpdateCollapsibleSection UpdateSection, Action 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 { 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(); 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; _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}"; } } }