diff --git a/AIChatPanel.xaml.cs b/AIChatPanel.xaml.cs index e6ef214..15ae62a 100644 --- a/AIChatPanel.xaml.cs +++ b/AIChatPanel.xaml.cs @@ -10,6 +10,8 @@ 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; @@ -96,6 +98,7 @@ namespace AnotherReplayReader private (Paragraph? Think, Paragraph? Content)? _currentContent; private Paragraph? _lastContentParagraph; private bool _suppressDisplay; + private bool _nextThinkingIsContinuation; // 进度计时 private TimeSpan _previousTotalTime; @@ -106,6 +109,18 @@ namespace AnotherReplayReader 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; @@ -199,11 +214,22 @@ namespace AnotherReplayReader _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; @@ -291,6 +317,7 @@ namespace AnotherReplayReader FinishCurrentContent(); var requestContext = GetRequestContext(); _systemPrompt = AIAnalyze.GetSystemPrompt(replay, players, GetPromptSettings?.Invoke()); + BeginStage("准备"); // ---- 机械分段(M2) ---- var model = requestContext.Model; @@ -351,6 +378,12 @@ namespace AnotherReplayReader // ---- 总览轮 ---- _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); @@ -367,10 +400,6 @@ namespace AnotherReplayReader ReportReasoningGuardResult(overviewResult); _overview = OverviewParser.Parse(overviewResult.Response); _overviewNarrative = _overview.Narrative; - AppendLog( - overviewAttempt == 0 ? "整局总览" : "整局总览(重试)", - overviewResult.Response, - false); overviewAttempt++; } while (_overview.Segments.IsEmpty && overviewAttempt < 2); @@ -438,6 +467,7 @@ namespace AnotherReplayReader _phaseText.Text = $"正在分析第 {segmentIndex}/{totalSegments} 段"; + BeginStage($"第{segmentIndex}段"); FinishCurrentContent(); // 记录回滚点 _blockCountBeforeSegment = _document.Blocks.Count; @@ -475,6 +505,7 @@ namespace AnotherReplayReader instruction + $"\r\n[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]", true); CheckAndLogContextUsage(messages, requestContext); + StartVersion("版本 1:正文", collapsedByDefault: false); // 回查循环(M3):同一段会话内,模型可多次请求远处原始区间 const int maxBackqueriesPerSegment = 3; @@ -524,19 +555,22 @@ namespace AnotherReplayReader 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", AIAnalyze.BuildBackqueryUserPrompt(string.Join("\n\n", pendingTexts)))); + "user", backqueryPrompt)); AppendLog( $"第{segmentIndex}段回查(第 {backqueryCount} 次)", - $"已提供 {pendingTexts.Count} 个区间,继续分析。", + $"已提供 {pendingTexts.Count} 个区间,继续分析。\n\n用户消息:\n" + backqueryPrompt, true); } FinishCurrentContent(); var validationResult = AIAnalysisValidation.ValidateMachineReadableClaims( segmentResponse, _factIndex, playerNameToIndex, structuredKnowledge); + AppendMachineJsonAndValidation(segmentResponse, validationResult); if (validationResult.HasIssues) { AppendLog( @@ -555,15 +589,16 @@ namespace AnotherReplayReader 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); + AppendLog( + $"第{segmentIndex}段修订", + $"验证器发现 {issueCount} 个需要修正/降级的问题,正在请求 AI 修正...\n\n用户消息:\n" + + revisionPrompt, + true); var revisionMessages = segmentMessages .Add(new AIAnalyze.ChatMessage("assistant", segmentResponse)) .Add(new AIAnalyze.ChatMessage("user", revisionPrompt)); @@ -595,7 +630,10 @@ namespace AnotherReplayReader finalResponse = revisionResult.Response; finalValidation = AIAnalysisValidation.ValidateMachineReadableClaims( revisionResult.Response, _factIndex, playerNameToIndex, structuredKnowledge); - ReplaceSegmentContent(finalResponse); + CollapseCurrentVersion(); + StartVersion("版本 2:修订后正文", collapsedByDefault: false); + AppendDirectText(finalResponse); + AppendMachineJsonAndValidation(finalResponse, finalValidation); AppendLog( "修订完成", "已采用修正后的分析。" + (finalValidation.HasIssues @@ -639,6 +677,7 @@ namespace AnotherReplayReader _linkedCts.Token.ThrowIfCancellationRequested(); _phaseText.Text = "正在生成总结..."; + BeginStage("总结"); FinishCurrentContent(); _blockCountBeforeSegment = _document.Blocks.Count; @@ -671,6 +710,7 @@ namespace AnotherReplayReader 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, @@ -688,6 +728,7 @@ namespace AnotherReplayReader var backqueries = BackqueryParser.Parse(result.Response); if (backqueries.IsEmpty) { + AppendLog("总结完成", "已生成最终总结。", false); return; } @@ -715,16 +756,19 @@ namespace AnotherReplayReader } _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", - AIAnalyze.BuildBackqueryUserPrompt(string.Join("\n\n", pendingTexts)))); + .Add(new AIAnalyze.ChatMessage("user", backqueryPrompt)); AppendLog( "总结回查", - $"已提供 {pendingTexts.Count} 个区间,正在生成最终总结。", + $"已提供 {pendingTexts.Count} 个区间,正在生成最终总结。\n\n用户消息:\n" + + backqueryPrompt, true); CheckAndLogContextUsage(backqueryMessages, requestContext); + CollapseCurrentVersion(); + StartVersion("版本 2:最终总结", collapsedByDefault: false); var finalSummary = await _analyzer!.CompleteAsync( backqueryMessages, requestContext, @@ -815,46 +859,31 @@ namespace AnotherReplayReader private void ReportReasoningGuardResult(AIAnalyze.Result result) { - if (!result.ReasoningInterrupted - && !result.ContinuationApplied - && string.IsNullOrWhiteSpace(result.ReasoningContinuationError)) + if (string.IsNullOrWhiteSpace(result.ReasoningContinuationError)) { return; } - if (!string.IsNullOrWhiteSpace(result.ReasoningContinuationError)) - { - AppendLog("推理保护警告", result.ReasoningContinuationError, false); - } - else if (result.ContinuationApplied) - { - AppendLog("推理保护", "已请求 AI 尽快收尾,并完成最终回答。", false); - } - else if (result.ReasoningInterrupted) - { - AppendLog("推理保护", "检测到推理内容超限,正在处理续写结果。", false); - } - - if (!string.IsNullOrWhiteSpace(result.ReasoningOriginalRequestJson)) - { - AppendLog( - "推理保护:首次请求消息", - result.ReasoningOriginalRequestJson, - true); - } - if (!string.IsNullOrWhiteSpace(result.ReasoningContinuationRequestJson)) - { - AppendLog( - "推理保护:续写请求完整消息", - result.ReasoningContinuationRequestJson, - true); - } + AppendLog("推理保护警告", result.ReasoningContinuationError, false, _currentStage); } private void StartThinkingBlock() { _thinkingStartTime = DateTime.Now; - var (_, content, update) = CreateCollapsibleSection("💭 AI 思考中...", collapsedByDefault: false); + 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; @@ -873,35 +902,25 @@ namespace AnotherReplayReader var timeText = elapsed < 60 ? $"{elapsed:F0} 秒" : $"{elapsed / 60:F1} 分钟"; + var stage = _currentStage ?? "分析"; // 自动折叠:移除段落,更新按钮文字 - _updateCurrentThinkingSection($"💭 AI 已思考完毕(用时 {timeText})", true); + _updateCurrentThinkingSection( + $"💭 [{stage}] AI 已思考完毕(用时 {timeText})", + true); _updateCurrentThinkingSection = null; } private void StartContentParagraph() { - _currentContent = (_currentContent?.Think, Content: new Paragraph()); + EnsureVersionBodyAdded(); + var body = _currentVersionBody ?? new Paragraph(); + _currentContent = (_currentContent?.Think, Content: body); _lastContentParagraph = _currentContent.Value.Content; - _document.Blocks.Add(_currentContent.Value.Content); - } - - private void ReplaceSegmentContent(string text) - { - FinishCurrentContent(); - // 移除本段已显示的全部块(草稿/回查中间内容),只保留最终修正版 - while (_document.Blocks.Count > _blockCountBeforeSegment) + if (_currentVersionBody is null) { - _document.Blocks.Remove(_document.Blocks.LastBlock); + GetStageBlocks(_currentStage).Add(body); } - _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) @@ -922,9 +941,414 @@ namespace AnotherReplayReader }); } - // ---- 折叠块辅助 ---- - private (Section Section, Paragraph Content, UpdateCollapsibleSection UpdateSection) - CreateCollapsibleSection(string title, bool collapsedByDefault) + // ---- 阶段化与折叠块辅助 ---- + 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 AddBlock) + CreateCollapsibleSection( + string title, + bool collapsedByDefault, + BlockCollection? parent = null) { var currentTitle = title; var currentlyCollapsed = collapsedByDefault; @@ -932,6 +1356,7 @@ namespace AnotherReplayReader 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, @@ -951,17 +1376,42 @@ namespace AnotherReplayReader run.Text = currentlyCollapsed ? $"{currentTitle} ⯈" : $"{currentTitle} ⯆"; if (currentlyCollapsed) { - section.Blocks.Remove(content); + foreach (var block in contentBlocks) + { + section.Blocks.Remove(block); + } } else { - if (!section.Blocks.Contains(content)) + foreach (var block in contentBlocks) { - section.Blocks.Add(content); + 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); @@ -977,32 +1427,60 @@ namespace AnotherReplayReader UpdateCollapsibleSection(currentTitle, collapsedByDefault); - _document.Blocks.Add(section); + (parent ?? _document.Blocks).Add(section); - return (section, content, UpdateCollapsibleSection); + return (section, content, UpdateCollapsibleSection, AddBlock); } - private void AppendLog(string title, string? details, bool collapsed) + private void AppendLog( + string title, + string? details, + bool collapsed, + string? stage = null) { - AutoScroll(); - FinishCurrentContent(); + 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, }); - _document.Blocks.Add(paragraph); + if (addBlock is { } add) + { + add(paragraph); + } + else + { + parent!.Add(paragraph); + } return; } - var logParagraph = CreateCollapsibleSection(title, collapsed).Content; - logParagraph.Inlines.Add(new Run(details) + var (logSection, logParagraph, _, _) = CreateCollapsibleSection( + title, + collapsed, + parent); + if (addBlock is { } addLog) { - Foreground = LogStyle.Foreground, - FontSize = LogStyle.FontSize - }); + 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 更新与计时 ---- @@ -1024,6 +1502,7 @@ namespace AnotherReplayReader { // discard everything in _chunkQueue while (_chunkQueue.TryDequeue(out _)) { } + _pendingUiActions.Clear(); if (_uiTimer is not null) { _uiTimer.Stop(); @@ -1038,6 +1517,7 @@ namespace AnotherReplayReader private void OnUiTimerTick(object? sender, EventArgs e) { FlushPendingChunks(); + FlushPendingUiActions(); var now = DateTime.Now; var elapsed = now - _analysisStartTime; @@ -1072,7 +1552,23 @@ namespace AnotherReplayReader { while (_chunkQueue.TryDequeue(out var data)) { - _currentOutputChars += data.Chunk.Text.Length; + 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; } @@ -1111,8 +1607,16 @@ namespace AnotherReplayReader 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) diff --git a/AI_REASONING_CONTINUATION_RESEARCH.md b/AI_REASONING_CONTINUATION_RESEARCH.md index bfbd6a1..d42a183 100644 --- a/AI_REASONING_CONTINUATION_RESEARCH.md +++ b/AI_REASONING_CONTINUATION_RESEARCH.md @@ -241,6 +241,8 @@ system - 保留推理在其末尾依次追加 `[INTERNAL_REASONING_TRUNCATED]` 与中文收尾句;收尾句不插入中间 checkpoint,也不提及“token limit/被截断”等技术细节。 - 续写工具结果包含“进入收尾阶段、立即停止展开、直接输出最终结果”和“最后一句已经宣告收尾,请立即执行”等强化措辞。 - UI 在触发推理保护后提供两个可折叠日志:首次请求消息与续写请求完整消息;用户可展开查看完整 `messages`、`tools`、`tool_choice` 和收尾指令。 +- UI 进一步改为阶段化时间线:推理保护事件与诊断在续写思考块之前输出;修订保留旧版本并折叠,最新版本展开;机器可读声明 JSON 与验证结果独立展示。 +- UI 正文在成功提取机器可读声明后会移除 JSON 原文;推理保护诊断显示可读摘要(消息数变化、新增 assistant/tool 消息),完整续写 JSON 作为折叠附件;总览、修订和回查的 user prompt 会以折叠日志展示。 - 默认测试新增 `ReasoningGuard` 套件后总计 173 项通过;真实 API A/B 仍需要用户手工运行 `ARR_AI_E2E=1` 验证。 - 可选真实环境测试为 `OpenCodeGoGuardE2e`:设置 `ARR_AI_GUARD_E2E=1` 启用,报告路径可用 `ARR_AI_GUARD_REPORT_PATH` 指定。 diff --git a/PLAN_ai_analysis_v2.md b/PLAN_ai_analysis_v2.md index 8a4182b..7568f9a 100644 --- a/PLAN_ai_analysis_v2.md +++ b/PLAN_ai_analysis_v2.md @@ -54,6 +54,10 @@ - 续写只在保留推理末尾依次追加 `[INTERNAL_REASONING_TRUNCATED]` 与中文收尾句,不插入中间 checkpoint;首请求不携带 `tools`,续写请求才注入工具定义和 `tool_choice=none`。 - `ChatMessage` 支持 `reasoning_content`/`tool_calls`/`tool_call_id`,`Result` 保留完整推理、中断/续写状态与预算/错误信息。 - UI 触发推理保护后会显示首次请求与续写请求的完整消息日志;日志默认折叠,用户可展开检查 `messages`/`tools`/`tool_choice`。 +- UI 改为按阶段分组:总览/各段/总结各自独立容器;修订时保留旧版本并折叠,最新版本默认展开;机器可读声明 JSON 与验证结果作为独立折叠块,不再混在正文中。 +- 推理保护诊断改为在触发时通过流式事件输出,位于续写思考块之前;总览重复日志已移除。 +- 成功提取机器可读声明后,UI 正文会移除 `[机器可读声明]` JSON,只在折叠块中显示一次;推理保护诊断改为“消息数变化 + 新增 assistant/tool 消息摘要”,完整续写 JSON 仍作为折叠附件。 +- 总览、分段修订、分段回查和总结回查都会显示实际发送的 user prompt(默认折叠)。 - `AiV2.Tests` 新增 `ReasoningGuard` 测试套件;默认测试总计 173 项通过。 ## 1. 背景与目标 diff --git a/ReplayFile/RA3Commands.cs b/ReplayFile/RA3Commands.cs index 1d678eb..82e99be 100644 --- a/ReplayFile/RA3Commands.cs +++ b/ReplayFile/RA3Commands.cs @@ -102,11 +102,11 @@ namespace AnotherReplayReader.ReplayFile [0x20A] = "出售建筑", // 522 // 523 - [0x20C] = "从进驻的建筑撤出(?)", // 524 + [0x20C] = "从进驻的建筑或载具撤出(?)", // 524 [0x20D] = "集火攻击", // 525 [0x20E] = "强制攻击单位(Ctrl)", // 526 [0x20F] = "强制攻击地板(Ctrl)", // 527 - [0x210] = "进驻建筑", // 528 + [0x210] = "进驻建筑或载具", // 528 // 529 [0x212] = "命令矿车交矿", // 530 // 531 diff --git a/Utils/AIAnalyze.cs b/Utils/AIAnalyze.cs index 3e181ed..c55c0e0 100644 --- a/Utils/AIAnalyze.cs +++ b/Utils/AIAnalyze.cs @@ -1211,6 +1211,7 @@ PlayerA: 开始出兵 { Reasoning, ReasoningGuard, + ReasoningGuardRequest, Content, Error, Json @@ -1330,6 +1331,21 @@ PlayerA: 开始出兵 continuationBudgetWarning = continuationCheck.Message; } + onChunk?.Invoke(new AIChunk + { + Type = AIChunkType.ReasoningGuardRequest, + Text = JsonSerializer.Serialize( + new + { + originalRequestJson, + continuationRequestJson + }, + new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }) + }); + Result secondResult = default; var continuationError = string.Empty; try