wip
This commit is contained in:
@@ -0,0 +1,834 @@
|
||||
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; }
|
||||
|
||||
// ---------- 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);
|
||||
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);
|
||||
_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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user