ui
This commit is contained in:
+587
-83
@@ -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<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;
|
||||
@@ -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<Block> 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<Block> { 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)
|
||||
|
||||
@@ -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` 指定。
|
||||
|
||||
|
||||
@@ -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. 背景与目标
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user