844 lines
31 KiB
C#
844 lines
31 KiB
C#
using AnotherReplayReader.Apm;
|
||
using AnotherReplayReader.ReplayFile;
|
||
using AnotherReplayReader.Utils;
|
||
using System;
|
||
using System.Collections;
|
||
using System.Collections.Concurrent;
|
||
using System.Collections.Generic;
|
||
using System.Collections.Immutable;
|
||
using System.Diagnostics;
|
||
using System.Diagnostics.Tracing;
|
||
using System.Text;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using System.Windows;
|
||
using System.Windows.Controls;
|
||
using System.Windows.Documents;
|
||
using System.Windows.Media;
|
||
using System.Windows.Threading;
|
||
using TechnologyAssembler.Core.Relo;
|
||
using static AnotherReplayReader.AIProviderSettingsControl;
|
||
using static AnotherReplayReader.Utils.AIAnalyzeUI;
|
||
using static System.Net.Mime.MediaTypeNames;
|
||
|
||
namespace AnotherReplayReader
|
||
{
|
||
public partial class AIChatPanel : UserControl
|
||
{
|
||
private class OnDisposeAction(Action action) : IDisposable
|
||
{
|
||
private Action? _action = action ?? throw new ArgumentNullException(nameof(action));
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_action is { } action)
|
||
{
|
||
_action = null;
|
||
action();
|
||
}
|
||
}
|
||
}
|
||
public record ParagraphStyle(double FontSize, Brush Foreground);
|
||
|
||
private delegate void UpdateCollapsibleSection(string title, bool collapse);
|
||
|
||
public static readonly FontFamily DefaultFontFamily = new("Microsoft YaHei UI");
|
||
public static readonly int DefaultFontSize = 14;
|
||
public static readonly int DetailsFontSize = 12;
|
||
public static readonly ParagraphStyle ContentStyle = new(DefaultFontSize, Brushes.Black);
|
||
public static readonly ParagraphStyle CollapsibleSectionHeaderStyle = new(DefaultFontSize, Brushes.DimGray);
|
||
public static readonly ParagraphStyle CollapsibleSectionHeaderHoverStyle = new(DefaultFontSize, Brushes.DodgerBlue);
|
||
public static readonly ParagraphStyle ThinkStyle = new(DetailsFontSize, Brushes.DimGray);
|
||
public static readonly ParagraphStyle LogStyle = new(DetailsFontSize, Brushes.DimGray);
|
||
public static readonly ParagraphStyle ErrorStyle = new(DefaultFontSize, Brushes.Red);
|
||
|
||
// ---------- fields ----------
|
||
private FlowDocument _document;
|
||
private ScrollViewer? _scrollViewer;
|
||
|
||
// 正在进行的请求相关
|
||
private CancellationTokenSource? _internalCts;
|
||
private CancellationToken? _externalCancelToken;
|
||
private CancellationTokenSource? _linkedCts;
|
||
private AIAnalyze? _analyzer;
|
||
private TimeIndexedPrefixSums? _eventCounts;
|
||
|
||
// 分析状态
|
||
private 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 TimeSpan _previousTotalTime;
|
||
private int _previousTotalChars;
|
||
private DateTimeOffset _analysisStartTime;
|
||
private int _currentOutputChars;
|
||
private EmaSpeed? _emaSpeed;
|
||
private DispatcherTimer? _uiTimer;
|
||
// 缓冲区
|
||
private readonly ConcurrentQueue<(AIAnalyze.AIChunk Chunk, DateTimeOffset Time)> _chunkQueue = new();
|
||
|
||
// Token 累计
|
||
private int _totalPromptTokens;
|
||
private int _totalCompletionTokens;
|
||
|
||
// ---------- properties ----------
|
||
// 外部注入:每次请求前调用获取最新配置
|
||
// 委托类型变更
|
||
public Func<AiRequestContext>? GetRequestContext { get; set; }
|
||
public Func<AiPromptSettings>? GetPromptSettings { get; set; }
|
||
|
||
// ---------- methods ----------
|
||
|
||
// ---- constructor & reset ----
|
||
public AIChatPanel()
|
||
{
|
||
InitializeComponent();
|
||
_document = new FlowDocument
|
||
{
|
||
FontFamily = DefaultFontFamily,
|
||
FontSize = DefaultFontSize,
|
||
|
||
PagePadding = new Thickness(0),
|
||
|
||
ColumnWidth = 999999,
|
||
|
||
TextAlignment = TextAlignment.Left,
|
||
};
|
||
TextOptions.SetTextFormattingMode(_document, TextFormattingMode.Display);
|
||
TextOptions.SetTextRenderingMode(_document, TextRenderingMode.ClearType);
|
||
_outputViewer.Document = _document;
|
||
_outputViewer.Loaded += OnOutputViewerLoaded;
|
||
ResetInternal();
|
||
}
|
||
|
||
private void OnOutputViewerLoaded(object? sender, RoutedEventArgs ea)
|
||
{
|
||
ScrollViewer? GetScrollViewer(DependencyObject dep)
|
||
{
|
||
if (dep is ScrollViewer sv)
|
||
{
|
||
return sv;
|
||
}
|
||
|
||
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(dep); ++i)
|
||
{
|
||
var child = VisualTreeHelper.GetChild(dep, i);
|
||
var result = GetScrollViewer(child);
|
||
if (result != null)
|
||
{
|
||
return result;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
_scrollViewer = GetScrollViewer(_outputViewer);
|
||
}
|
||
|
||
public void Reset()
|
||
{
|
||
if (_isRunning)
|
||
{
|
||
_internalCts?.Cancel();
|
||
}
|
||
ResetInternal();
|
||
}
|
||
|
||
private void ResetInternal()
|
||
{
|
||
_isRunning = false;
|
||
_isFailed = false;
|
||
_canRetry = false;
|
||
_lastSuccessfulSegment = -1;
|
||
_blockCountBeforeSegment = 0;
|
||
_analyzer = null;
|
||
_eventCounts = null;
|
||
|
||
_document.Blocks.Clear();
|
||
_updateCurrentThinkingSection = null;
|
||
_thinkBlockWasExpanded = false;
|
||
_currentContent = 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;
|
||
|
||
StopUiTimer();
|
||
UpdateButtons();
|
||
}
|
||
|
||
// ---- public API ----
|
||
internal async Task StartAnalysisAsync(
|
||
Replay replay,
|
||
ImmutableSortedDictionary<int, Player> players,
|
||
string replayData,
|
||
TimeIndexedPrefixSums eventCounts,
|
||
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;
|
||
|
||
FinishCurrentContent();
|
||
var requestContext = GetRequestContext();
|
||
|
||
var systemPrompt = AIAnalyze.GetSystemPrompt(replay, players, GetPromptSettings?.Invoke());
|
||
var userPrompt = AIAnalyze.BuildUserPrompt(replay.Mod, players, replayData, out var userPromptPrefix);
|
||
AppendLog($"让 AI 了解录像...",
|
||
userPromptPrefix
|
||
+ "[输入:操作信息流水账]\r\n"
|
||
+ $"[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
|
||
true);
|
||
|
||
#region info
|
||
var (systemPromptSize, systemPromptTokenCount) = AIAnalyze.EstimateTokenCount(systemPrompt);
|
||
var (userPromptSize, userPromptTokenCount) = AIAnalyze.EstimateTokenCount(userPrompt);
|
||
var totalPromptSize = systemPromptSize + userPromptSize;
|
||
var totalPromptTokenCount = systemPromptTokenCount + userPromptTokenCount;
|
||
var estimateText = $"系统提示词: {FormatNumber(systemPromptSize, true, "B")},约 {FormatNumber(systemPromptTokenCount, false)} Token\r\n" +
|
||
$"初始用户提示词: {FormatNumber(userPromptSize, true, "B")},约 {FormatNumber(userPromptTokenCount, false)} Token\r\n" +
|
||
$"总大小: {FormatNumber(totalPromptSize, true, "B")},约 {FormatNumber(totalPromptTokenCount, false)} Token";
|
||
AppendLog("初始阶段 Token 用量预测", estimateText, false);
|
||
#endregion
|
||
|
||
var result = await _analyzer.AnalyzeAsync(
|
||
systemPrompt,
|
||
userPrompt,
|
||
requestContext,
|
||
OnChunk,
|
||
_linkedCts.Token);
|
||
|
||
UpdateTokenDisplay(result);
|
||
|
||
var eventCountsBuilder = new StringBuilder();
|
||
foreach (var (Start, End, Description) in result.State.Segments)
|
||
{
|
||
var count = eventCounts.Query(Start, End);
|
||
eventCountsBuilder.AppendLine($"[{Start:mm\\:ss\\.ff} - {End:mm\\:ss\\.ff}],事件数: {count}");
|
||
}
|
||
AppendLog("各分段事件数量", eventCountsBuilder.ToString(), false);
|
||
|
||
// 分析完成,分段列表就绪
|
||
_lastSuccessfulSegment = 0;
|
||
|
||
// 分段分析循环
|
||
await ProcessSegmentsAsync(result.State, eventCounts);
|
||
|
||
// 总结
|
||
await ProcessSummaryAsync(eventCounts);
|
||
|
||
// 成功结束
|
||
_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;
|
||
}
|
||
}
|
||
|
||
// ---- 分析阶段 ----
|
||
private async Task ProcessSegmentsAsync(AIAnalyze.State lastState,
|
||
TimeIndexedPrefixSums eventCounts)
|
||
{
|
||
_analyzer!.SetState(lastState);
|
||
while (lastState.CurrentSegment < lastState.Segments.Count)
|
||
{
|
||
if (_linkedCts is null)
|
||
{
|
||
break;
|
||
}
|
||
_linkedCts.Token.ThrowIfCancellationRequested();
|
||
var seg = lastState.Segments[lastState.CurrentSegment];
|
||
var segmentIndex = lastState.CurrentSegment + 1;
|
||
var totalSegments = lastState.Segments.Count;
|
||
|
||
_phaseText.Text = $"正在分析第 {segmentIndex}/{totalSegments} 段";
|
||
|
||
FinishCurrentContent();
|
||
// 记录回滚点
|
||
_blockCountBeforeSegment = _document.Blocks.Count;
|
||
|
||
// 计算事件数
|
||
var eventCount = eventCounts.Query(seg.Start, seg.End);
|
||
var segmentPrompt = AIAnalyze.BuildSegmentUserPrompt(lastState.Segments, lastState.CurrentSegment, eventCount);
|
||
var requestContext = GetRequestContext!();
|
||
AppendLog($"让 AI 分析第{segmentIndex}段...",
|
||
segmentPrompt
|
||
+ $"\r\n[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
|
||
true);
|
||
var segmentResult = await _analyzer.ContinueAnalyzeAsync(
|
||
segmentPrompt,
|
||
requestContext,
|
||
OnChunk,
|
||
_linkedCts.Token);
|
||
var validationResult = AIAnalysisValidation.ValidateMachineReadableClaims(segmentResult.Response);
|
||
if (validationResult.HasIssues)
|
||
{
|
||
AppendLog(
|
||
$"第{segmentIndex}段机器可读声明检查",
|
||
AIAnalysisValidation.FormatIssues(validationResult.Issues),
|
||
false);
|
||
}
|
||
_lastSuccessfulSegment = lastState.CurrentSegment;
|
||
lastState = segmentResult.State;
|
||
UpdateTokenDisplay(segmentResult);
|
||
AppendLog($"第{segmentIndex}段完成。", null, true);
|
||
}
|
||
}
|
||
|
||
private async Task ProcessSummaryAsync(TimeIndexedPrefixSums eventCounts)
|
||
{
|
||
if (_linkedCts is null)
|
||
{
|
||
return;
|
||
}
|
||
_linkedCts.Token.ThrowIfCancellationRequested();
|
||
|
||
_phaseText.Text = "正在生成总结...";
|
||
FinishCurrentContent();
|
||
_blockCountBeforeSegment = _document.Blocks.Count;
|
||
|
||
var totalEvents = eventCounts.GetTotal();
|
||
var finalPrompt = AIAnalyze.BuildFinalUserPrompt(totalEvents);
|
||
var requestContext = GetRequestContext!();
|
||
AppendLog($"让 AI 生成总结...",
|
||
finalPrompt
|
||
+ $"\r\n[AI: {requestContext.Provider.Name}/{requestContext.Model.ModelId}]",
|
||
true);
|
||
|
||
var result = await _analyzer!.FinishAnalyzeAsync(
|
||
finalPrompt,
|
||
requestContext,
|
||
OnChunk,
|
||
_linkedCts.Token);
|
||
|
||
UpdateTokenDisplay(result);
|
||
}
|
||
|
||
// ---- 块追加与折叠 ----
|
||
private void OnChunk(AIAnalyze.AIChunk chunk)
|
||
{
|
||
_chunkQueue.Enqueue((chunk, DateTimeOffset.UtcNow));
|
||
}
|
||
|
||
private void StartThinkingBlock()
|
||
{
|
||
_thinkingStartTime = DateTime.Now;
|
||
var (_, content, update) = CreateCollapsibleSection("💭 AI 思考中...", collapsedByDefault: false);
|
||
_currentContent = (Think: content, _currentContent?.Content);
|
||
_updateCurrentThinkingSection = update;
|
||
_thinkBlockWasExpanded = true;
|
||
}
|
||
|
||
private void EndThinkingBlock()
|
||
{
|
||
_thinkBlockWasExpanded = false;
|
||
|
||
if (_updateCurrentThinkingSection is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var elapsed = (DateTime.Now - _thinkingStartTime).TotalSeconds;
|
||
var timeText = elapsed < 60
|
||
? $"{elapsed:F0} 秒"
|
||
: $"{elapsed / 60:F1} 分钟";
|
||
|
||
// 自动折叠:移除段落,更新按钮文字
|
||
_updateCurrentThinkingSection($"💭 AI 已思考完毕(用时 {timeText})", true);
|
||
_updateCurrentThinkingSection = null;
|
||
}
|
||
|
||
private void StartContentParagraph()
|
||
{
|
||
_currentContent = (_currentContent?.Think, Content: new Paragraph());
|
||
_document.Blocks.Add(_currentContent.Value.Content);
|
||
}
|
||
|
||
private void AppendToParagraph(Paragraph? paragraph, string text, ParagraphStyle style)
|
||
{
|
||
if (paragraph is null)
|
||
{
|
||
Debug.Instance.DebugMessage += "[AIChatPanel] AppendToParagraph: paragraph is null, creating a new content paragraph.\r\n";
|
||
|
||
// 未预期的情况,创建一个新的内容段落
|
||
StartContentParagraph();
|
||
paragraph = _currentContent!.Value.Content!;
|
||
}
|
||
|
||
paragraph.Inlines.Add(new Run(text)
|
||
{
|
||
Foreground = style.Foreground,
|
||
FontSize = style.FontSize
|
||
});
|
||
}
|
||
|
||
// ---- 折叠块辅助 ----
|
||
private (Section Section, Paragraph Content, UpdateCollapsibleSection UpdateSection)
|
||
CreateCollapsibleSection(string title, bool collapsedByDefault)
|
||
{
|
||
var currentTitle = title;
|
||
var currentlyCollapsed = collapsedByDefault;
|
||
|
||
var section = new Section();
|
||
var header = new Paragraph();
|
||
var content = new Paragraph();
|
||
var hyperLink = new Hyperlink(new Run(""))
|
||
{
|
||
Foreground = CollapsibleSectionHeaderStyle.Foreground,
|
||
FontSize = CollapsibleSectionHeaderStyle.FontSize,
|
||
TextDecorations = null,
|
||
};
|
||
section.Blocks.Add(header);
|
||
section.Blocks.Add(content);
|
||
header.Inlines.Add(hyperLink);
|
||
|
||
void UpdateCollapsibleSection(string newTitle, bool willBeCollapsed)
|
||
{
|
||
currentTitle = newTitle;
|
||
currentlyCollapsed = willBeCollapsed;
|
||
|
||
var run = (Run)hyperLink.Inlines.FirstInline;
|
||
run.Text = currentlyCollapsed ? $"{currentTitle} ⯈" : $"{currentTitle} ⯆";
|
||
if (currentlyCollapsed)
|
||
{
|
||
section.Blocks.Remove(content);
|
||
}
|
||
else
|
||
{
|
||
if (!section.Blocks.Contains(content))
|
||
{
|
||
section.Blocks.Add(content);
|
||
}
|
||
}
|
||
}
|
||
|
||
hyperLink.Click += (s, e) =>
|
||
{
|
||
UpdateCollapsibleSection(currentTitle, !currentlyCollapsed);
|
||
};
|
||
hyperLink.MouseEnter += (s, e) =>
|
||
{
|
||
hyperLink.Foreground = CollapsibleSectionHeaderHoverStyle.Foreground;
|
||
};
|
||
hyperLink.MouseLeave += (s, e) =>
|
||
{
|
||
hyperLink.Foreground = CollapsibleSectionHeaderStyle.Foreground;
|
||
};
|
||
|
||
UpdateCollapsibleSection(currentTitle, collapsedByDefault);
|
||
|
||
_document.Blocks.Add(section);
|
||
|
||
return (section, content, UpdateCollapsibleSection);
|
||
}
|
||
|
||
private void AppendLog(string title, string? details, bool collapsed)
|
||
{
|
||
AutoScroll();
|
||
FinishCurrentContent();
|
||
title = $"📋 {title}";
|
||
if (string.IsNullOrEmpty(details))
|
||
{
|
||
var paragraph = new Paragraph(new Run(title)
|
||
{
|
||
Foreground = LogStyle.Foreground,
|
||
});
|
||
_document.Blocks.Add(paragraph);
|
||
return;
|
||
}
|
||
|
||
var logParagraph = CreateCollapsibleSection(title, collapsed).Content;
|
||
logParagraph.Inlines.Add(new Run(details)
|
||
{
|
||
Foreground = LogStyle.Foreground,
|
||
FontSize = LogStyle.FontSize
|
||
});
|
||
}
|
||
|
||
// ---- UI 更新与计时 ----
|
||
private void StartUiTimer()
|
||
{
|
||
_analysisStartTime = DateTimeOffset.UtcNow;
|
||
_currentOutputChars = 0;
|
||
_emaSpeed = new EmaSpeed();
|
||
|
||
_uiTimer = new DispatcherTimer
|
||
{
|
||
Interval = TimeSpan.FromMilliseconds(100)
|
||
};
|
||
_uiTimer.Tick += OnUiTimerTick;
|
||
_uiTimer.Start();
|
||
}
|
||
|
||
private void StopUiTimer()
|
||
{
|
||
// discard everything in _chunkQueue
|
||
while (_chunkQueue.TryDequeue(out _)) { }
|
||
if (_uiTimer is not null)
|
||
{
|
||
_uiTimer.Stop();
|
||
_uiTimer.Tick -= OnUiTimerTick;
|
||
_uiTimer = null;
|
||
|
||
_previousTotalTime += DateTimeOffset.UtcNow - _analysisStartTime;
|
||
_previousTotalChars += _currentOutputChars;
|
||
}
|
||
}
|
||
|
||
private void OnUiTimerTick(object? sender, EventArgs e)
|
||
{
|
||
FlushPendingChunks();
|
||
|
||
var now = DateTime.Now;
|
||
var elapsed = now - _analysisStartTime;
|
||
var totalElapsed = _previousTotalTime + elapsed;
|
||
var totalChars = _previousTotalChars + _currentOutputChars;
|
||
_timeText.Text = $"{elapsed:mm\\:ss}";
|
||
_charProgressText.Text = $"总字数: {totalChars}";
|
||
|
||
if (_emaSpeed is not null)
|
||
{
|
||
var speed = _emaSpeed.GetDisplaySpeed(now);
|
||
var avgSpeed = elapsed.TotalSeconds > 0
|
||
? totalChars / totalElapsed.TotalSeconds
|
||
: 0;
|
||
_instantSpeedText.Text = $"{speed:0} 字/秒";
|
||
_avgSpeedText.Text = $"平均 {avgSpeed:0.00} 字/秒";
|
||
}
|
||
}
|
||
|
||
private void FinishCurrentContent()
|
||
{
|
||
FlushPendingChunks();
|
||
_currentContent = null;
|
||
}
|
||
|
||
private void FlushPendingChunks()
|
||
{
|
||
AutoScroll();
|
||
|
||
var thinkSb = new StringBuilder();
|
||
var contentSb = new StringBuilder();
|
||
var errorSb = new StringBuilder();
|
||
while (_chunkQueue.TryDequeue(out var data))
|
||
{
|
||
var (chunk, time) = data;
|
||
if (chunk.Type == AIAnalyze.AIChunkType.Reasoning)
|
||
{
|
||
if (_currentContent?.Think is null)
|
||
{
|
||
StartThinkingBlock();
|
||
}
|
||
thinkSb.Append(chunk.Text);
|
||
_currentOutputChars += chunk.Text.Length;
|
||
_emaSpeed?.ProcessEvent(chunk.Text.Length, time);
|
||
}
|
||
else if (chunk.Type == AIAnalyze.AIChunkType.Content)
|
||
{
|
||
if (_currentContent?.Content is null)
|
||
{
|
||
StartContentParagraph();
|
||
}
|
||
contentSb.Append(chunk.Text);
|
||
_currentOutputChars += chunk.Text.Length;
|
||
_emaSpeed?.ProcessEvent(chunk.Text.Length, time);
|
||
}
|
||
else if (chunk.Type == AIAnalyze.AIChunkType.Error)
|
||
{
|
||
errorSb.AppendLine(chunk.Text);
|
||
}
|
||
}
|
||
|
||
if (thinkSb.Length > 0)
|
||
{
|
||
AppendToParagraph(_currentContent?.Think, thinkSb.ToString(), ThinkStyle);
|
||
}
|
||
if (contentSb.Length > 0)
|
||
{
|
||
if (_thinkBlockWasExpanded)
|
||
{
|
||
EndThinkingBlock();
|
||
}
|
||
AppendToParagraph(_currentContent?.Content, contentSb.ToString(), ContentStyle);
|
||
}
|
||
if (errorSb.Length > 0)
|
||
{
|
||
AddErrorBlock(errorSb.ToString());
|
||
}
|
||
}
|
||
|
||
private void AutoScroll()
|
||
{
|
||
const double BottomThreshold = 20.0;
|
||
var isNearBottom = false;
|
||
if (_scrollViewer is not null)
|
||
{
|
||
isNearBottom = _scrollViewer.VerticalOffset + _scrollViewer.ViewportHeight
|
||
>= _scrollViewer.ExtentHeight - BottomThreshold;
|
||
}
|
||
if (isNearBottom)
|
||
{
|
||
Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() =>
|
||
{
|
||
_scrollViewer?.ScrollToEnd();
|
||
}));
|
||
}
|
||
}
|
||
|
||
// ---- Token 显示 ----
|
||
private void UpdateTokenDisplay(AIAnalyze.Result result)
|
||
{
|
||
_totalPromptTokens += result.PromptTokens ?? 0;
|
||
_totalCompletionTokens += result.CompletionTokens ?? 0;
|
||
var total = _totalPromptTokens + _totalCompletionTokens;
|
||
_currentTokensText.Text =
|
||
$"上次请求 Token:输入 {FormatNumber(result.PromptTokens, false)}"
|
||
+ $" 输出 {FormatNumber(result.CompletionTokens, false)}";
|
||
_conversationTokensText.Text =
|
||
$"累计 Token:输入 {FormatNumber(_totalPromptTokens, false)}"
|
||
+ $" 输出 {FormatNumber(_totalCompletionTokens, false)}"
|
||
+ $" 总计 {FormatNumber(total, false)}";
|
||
}
|
||
|
||
// ---- 回滚与错误显示 ----
|
||
private void RollbackToLastSavePoint()
|
||
{
|
||
FinishCurrentContent();
|
||
|
||
while (_document.Blocks.Count > _blockCountBeforeSegment)
|
||
{
|
||
_document.Blocks.Remove(_document.Blocks.LastBlock);
|
||
}
|
||
|
||
_updateCurrentThinkingSection = null;
|
||
_thinkBlockWasExpanded = false;
|
||
_currentContent = null;
|
||
}
|
||
|
||
private void AddErrorBlock(string message)
|
||
{
|
||
AutoScroll();
|
||
FinishCurrentContent();
|
||
var paragraph = new Paragraph(new Run(message)
|
||
{
|
||
Foreground = ErrorStyle.Foreground,
|
||
FontWeight = FontWeights.Bold
|
||
});
|
||
_document.Blocks.Add(paragraph);
|
||
}
|
||
|
||
// ---- 按钮动作 ----
|
||
private void OnCancelClick(object sender, RoutedEventArgs e)
|
||
{
|
||
_internalCts?.Cancel();
|
||
}
|
||
|
||
private async void OnRetryClick(object sender, RoutedEventArgs e)
|
||
{
|
||
if (!_canRetry || _analyzer is null || _eventCounts is null)
|
||
{
|
||
MessageBox.Show("无法重试:没有可用的分析器或事件计数。", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
|
||
return;
|
||
}
|
||
if (_externalCancelToken is not { } externalCancelToken)
|
||
{
|
||
MessageBox.Show("无法重试:外部取消令牌不可用。", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
|
||
return;
|
||
}
|
||
if (_analyzer.LastSuccessfulState.CurrentSegment < 0)
|
||
{
|
||
_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);
|
||
|
||
// 注意:此时 _analyzer.CurrentSegment 停留在失败的那一段
|
||
// 需要继续 ProcessSegmentsAsync,但需要将现有的 result.State 作为起点
|
||
await ProcessSegmentsAsync(_analyzer.LastSuccessfulState, _eventCounts);
|
||
|
||
// 总结
|
||
await ProcessSummaryAsync(_eventCounts);
|
||
}
|
||
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}";
|
||
}
|
||
}
|
||
}
|