diff --git a/AIChatPanel.xaml b/AIChatPanel.xaml
new file mode 100644
index 0000000..fb64541
--- /dev/null
+++ b/AIChatPanel.xaml
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/AIChatPanel.xaml.cs b/AIChatPanel.xaml.cs
new file mode 100644
index 0000000..60ffe51
--- /dev/null
+++ b/AIChatPanel.xaml.cs
@@ -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? 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 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}";
+ }
+ }
+}
\ No newline at end of file
diff --git a/AIProviderSettingsControl.xaml b/AIProviderSettingsControl.xaml
new file mode 100644
index 0000000..765814f
--- /dev/null
+++ b/AIProviderSettingsControl.xaml
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/AIProviderSettingsControl.xaml.cs b/AIProviderSettingsControl.xaml.cs
new file mode 100644
index 0000000..6545844
--- /dev/null
+++ b/AIProviderSettingsControl.xaml.cs
@@ -0,0 +1,380 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Text.Json;
+using System.Web.Routing;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Threading;
+
+namespace AnotherReplayReader
+{
+ public partial class AIProviderSettingsControl : UserControl
+ {
+ // ---------- fields ----------
+ private AiSettings _settings;
+ private AiProvider? _currentProvider;
+ private AiModel? _currentModel;
+
+ // 为了下拉框显示,内部包装
+ private record ModelDisplayItem(AiModel Model)
+ {
+ public string DisplayText =>
+ $"{Model.ModelId}{(Model.ContextLength == 0 ? " (未知性能)" : "")}";
+ }
+
+ // ---------- constructor ----------
+ public AIProviderSettingsControl()
+ {
+ InitializeComponent();
+ _settings = AiSettings.Load();
+ RefreshProviderList();
+ if (_settings.Providers.Count > 0)
+ {
+ _providerListBox.SelectedIndex = 0;
+ }
+ }
+
+ // ---------- public API ----------
+ public AiRequestContext? GetCurrentContext()
+ {
+ if (_currentProvider is null || _currentModel is null)
+ {
+ return null;
+ }
+ return new AiRequestContext(_currentProvider, _currentModel);
+ }
+
+ // ---------- Provider 列表管理 ----------
+ private void RefreshProviderList()
+ {
+ _providerListBox.ItemsSource = new ObservableCollection(_settings.Providers);
+ _providerListBox.DisplayMemberPath = "Name";
+ }
+
+ private void OnProviderSelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ _currentProvider = _providerListBox.SelectedItem as AiProvider;
+ if (_currentProvider is null)
+ {
+ return;
+ }
+
+ _providerNameBox.Text = _currentProvider.Name;
+ _providerUrlBox.Text = _currentProvider.BaseUrl;
+ _providerKeyBox.Password = _currentProvider.ApiKey;
+ _temperatureBox.Text = _currentProvider.DefaultTemperature.ToString();
+ _topPBox.Text = _currentProvider.DefaultTopP.ToString();
+ _maxTokensBox.Text = _currentProvider.DefaultMaxTokens.ToString();
+
+ RefreshModelList();
+ }
+
+ private void OnAddProviderClick(object sender, RoutedEventArgs e)
+ {
+ var newProvider = new AiProvider
+ {
+ Name = "新服务",
+ BaseUrl = "https://api.openai.com/v1"
+ };
+ _settings.Providers.Add(newProvider);
+ _settings.Save();
+ RefreshProviderList();
+ _providerListBox.SelectedItem = newProvider;
+ }
+
+ private void OnRemoveProviderClick(object sender, RoutedEventArgs e)
+ {
+ if (_currentProvider is null)
+ {
+ return;
+ }
+
+ var result = MessageBox.Show(
+ $"确定要删除服务 \"{_currentProvider.Name}\" 吗?",
+ "确认删除", MessageBoxButton.YesNo);
+ if (result != MessageBoxResult.Yes)
+ {
+ return;
+ }
+
+ _settings.Providers.Remove(_currentProvider);
+ _settings.Save();
+ RefreshProviderList();
+ if (_settings.Providers.Count > 0)
+ {
+ _providerListBox.SelectedIndex = 0;
+ }
+ else
+ {
+ _currentProvider = null;
+ ClearProviderFields();
+ }
+ }
+
+ private void OnApplyProviderClick(object sender, RoutedEventArgs e)
+ {
+ if (_currentProvider is null)
+ {
+ return;
+ }
+
+ _currentProvider.Name = _providerNameBox.Text;
+ _currentProvider.BaseUrl = _providerUrlBox.Text;
+ _currentProvider.ApiKey = _providerKeyBox.Password;
+ double.TryParse(_temperatureBox.Text, out double temp);
+ _currentProvider.DefaultTemperature = temp;
+ double.TryParse(_topPBox.Text, out double topP);
+ _currentProvider.DefaultTopP = topP;
+ int.TryParse(_maxTokensBox.Text, out int maxTokens);
+ _currentProvider.DefaultMaxTokens = maxTokens;
+
+ _settings.Save();
+ RefreshProviderList();
+ _providerListBox.SelectedItem = _currentProvider;
+ MessageBox.Show("服务配置已保存", "信息", MessageBoxButton.OK,
+ MessageBoxImage.Information);
+ }
+
+ private void ClearProviderFields()
+ {
+ _providerNameBox.Text = "";
+ _providerUrlBox.Text = "";
+ _providerKeyBox.Password = "";
+ _temperatureBox.Text = "";
+ _topPBox.Text = "";
+ _maxTokensBox.Text = "";
+ _modelComboBox.ItemsSource = null;
+ }
+
+ // ---------- 模型管理 ----------
+ private void RefreshModelList()
+ {
+ if (_currentProvider is null)
+ {
+ _modelComboBox.ItemsSource = null;
+ return;
+ }
+
+ var items = _currentProvider.Models
+ .Select(m => new ModelDisplayItem(Model: m ))
+ .ToList();
+ _modelComboBox.ItemsSource = new ObservableCollection(items);
+
+ if (items.Count > 0)
+ {
+ _modelComboBox.SelectedIndex = 0;
+ }
+ else
+ {
+ ClearModelFields();
+ }
+ }
+
+ private void OnModelSelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ var selected = _modelComboBox.SelectedItem as ModelDisplayItem;
+ _currentModel = selected?.Model;
+
+ if (_currentModel is null)
+ {
+ ClearModelFields();
+ return;
+ }
+
+ _modelIdBox.Text = _currentModel.ModelId;
+ _contextLengthBox.Text = _currentModel.ContextLength.ToString();
+ _supportsSseCheck.IsChecked = _currentModel.IsStream;
+
+ // 显示 ExtraParameters 为缩进 JSON
+ var json = JsonSerializer.Serialize(
+ _currentModel.ExtraParameters,
+ new JsonSerializerOptions { WriteIndented = true });
+ _extraParamsBox.Text = json;
+ }
+
+ private void OnFetchModelsClick(object sender, RoutedEventArgs e)
+ {
+ if (_currentProvider is null)
+ {
+ MessageBox.Show("请先选择一个服务");
+ return;
+ }
+ if (string.IsNullOrEmpty(_currentProvider.BaseUrl))
+ {
+ MessageBox.Show("请先填写 Base URL");
+ return;
+ }
+ if (string.IsNullOrEmpty(_currentProvider.ApiKey))
+ {
+ MessageBox.Show("请先填写 API Key");
+ return;
+ }
+
+ // 异步获取
+ Dispatcher.Invoke(async () =>
+ {
+ try
+ {
+ _modelStatusText.Text = "正在获取模型列表...";
+ var ids = await AiModelFetcher.FetchModelsAsync(
+ _currentProvider!.BaseUrl,
+ _currentProvider.ApiKey);
+
+ // 将获取的 ID 与现有模型合并,新 ID 若不存在则添加
+ var knownModels = KnownModels.GetAll();
+ foreach (var id in ids)
+ {
+ if (_currentProvider.Models.All(m => m.ModelId != id))
+ {
+ var known = knownModels
+ .OrderBy(k => KnownModels.GetSimilarity(k.ModelId, id))
+ .FirstOrDefault();
+ if (known != null && KnownModels.GetSimilarity(known.ModelId, id) >= KnownModels.SimilarityThreshold)
+ {
+ _currentProvider.Models.Add(new AiModel
+ {
+ ModelId = id,
+ DisplayName = id,
+ ContextLength = known.ContextLength,
+ IsStream = known.IsStream,
+ ExtraParameters = new Dictionary(known.ExtraParameters)
+ });
+ }
+ else
+ {
+ _currentProvider.Models.Add(new AiModel
+ {
+ ModelId = id,
+ ContextLength = 0,
+ IsStream = false
+ });
+ }
+ }
+ }
+
+ _settings.Save();
+ RefreshModelList();
+ _modelStatusText.Text = $"获取成功,共 {ids.Count} 个模型";
+ }
+ catch (Exception ex)
+ {
+ _modelStatusText.Text = $"获取失败: {ex.Message}";
+ }
+ });
+ }
+
+ private void OnAddCustomModelClick(object sender, RoutedEventArgs e)
+ {
+ if (_currentProvider is null)
+ {
+ return;
+ }
+
+ var newModel = new AiModel
+ {
+ ModelId = "custom-model-id",
+ ContextLength = 0,
+ IsStream = false
+ };
+ _currentProvider.Models.Add(newModel);
+ _settings.Save();
+ RefreshModelList();
+ _modelComboBox.SelectedItem = _modelComboBox.Items
+ .OfType()
+ .Last();
+ }
+
+ private void OnRemoveModelClick(object sender, RoutedEventArgs e)
+ {
+ if (_currentProvider is null || _currentModel is null)
+ {
+ return;
+ }
+
+ var result = MessageBox.Show(
+ $"确定要删除模型 \"{_currentModel.ModelId}\" 吗?",
+ "确认删除", MessageBoxButton.YesNo);
+ if (result != MessageBoxResult.Yes)
+ {
+ return;
+ }
+
+ _currentProvider.Models.Remove(_currentModel);
+ _settings.Save();
+ RefreshModelList();
+ }
+
+ private void OnApplyModelClick(object sender, RoutedEventArgs e)
+ {
+ if (_currentModel is null)
+ {
+ return;
+ }
+
+ _currentModel.ModelId = _modelIdBox.Text;
+ if (int.TryParse(_contextLengthBox.Text, out int ctxLen))
+ {
+ _currentModel.ContextLength = ctxLen;
+ }
+ _currentModel.IsStream = _supportsSseCheck.IsChecked == true;
+
+ try
+ {
+ var dict = JsonSerializer.Deserialize>(
+ _extraParamsBox.Text);
+ if (dict is not null)
+ {
+ _currentModel.ExtraParameters = dict;
+ }
+ }
+ catch
+ {
+ MessageBox.Show("高级参数格式错误,应为 JSON 键值对",
+ "错误", MessageBoxButton.OK, MessageBoxImage.Error);
+ return;
+ }
+
+ _settings.Save();
+ RefreshModelList();
+ MessageBox.Show("模型修改已保存", "信息", MessageBoxButton.OK);
+ }
+
+ private void OnFillFromKnownModelsClick(object sender, RoutedEventArgs e)
+ {
+ if (_currentProvider is null || _currentModel is null)
+ {
+ return;
+ }
+
+ var known = KnownModels.GetAll()
+ .OrderBy(k => KnownModels.GetSimilarity(k.ModelId, _currentModel.ModelId))
+ .FirstOrDefault();
+ if (known is not null
+ && KnownModels.GetSimilarity(known.ModelId, _currentModel.ModelId) >= KnownModels.SimilarityThreshold)
+ {
+ _currentModel.ExtraParameters = new Dictionary(known.ExtraParameters);
+ _currentModel.IsStream = known.IsStream;
+ _currentModel.ContextLength = known.ContextLength;
+ RefreshModelList();
+ _modelComboBox.SelectedItem = _modelComboBox.Items
+ .OfType()
+ .FirstOrDefault(m => m.Model.ModelId == _currentModel.ModelId);
+ MessageBox.Show("已从已知模板填充", "信息", MessageBoxButton.OK);
+ }
+ else
+ {
+ MessageBox.Show("未找到已知模板", "信息", MessageBoxButton.OK);
+ }
+ }
+
+ private void ClearModelFields()
+ {
+ _modelIdBox.Text = "";
+ _contextLengthBox.Text = "";
+ _supportsSseCheck.IsChecked = false;
+ _extraParamsBox.Text = "";
+ }
+ }
+}
\ No newline at end of file
diff --git a/EventDump.xaml b/EventDump.xaml
index e242ceb..c9a6f2b 100644
--- a/EventDump.xaml
+++ b/EventDump.xaml
@@ -39,88 +39,11 @@
UndoLimit="0"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
diff --git a/EventDump.xaml.cs b/EventDump.xaml.cs
index 74d8018..a056f75 100644
--- a/EventDump.xaml.cs
+++ b/EventDump.xaml.cs
@@ -16,6 +16,7 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
using System.Xml.Linq;
+using static AnotherReplayReader.AIProviderSettingsControl;
namespace AnotherReplayReader
{
@@ -76,8 +77,7 @@ namespace AnotherReplayReader
private readonly CancellationTokenSource _cancellation = new();
private Model _model = new();
private string? _cached;
- private AIAnalyze.TimeIndexedPrefixSums? _cachedPrefixSums;
- private DateTimeOffset _aiStartTime;
+ private TimeIndexedPrefixSums? _cachedPrefixSums;
public EventDump()
{
@@ -180,7 +180,7 @@ namespace AnotherReplayReader
_tokenUsageLabel.Content = $"大小: {bytesCount / 1024.0:0.00} KiB,估计Token数: {estimatedTokenCount / 1000.0:0.00} K";
}
- private static string GeneratePlainText(Model model, out AIAnalyze.TimeIndexedPrefixSums prefixSums)
+ private static string GeneratePlainText(Model model, out TimeIndexedPrefixSums prefixSums)
{
prefixSums = new([], []);
var sb = new StringBuilder();
@@ -431,194 +431,26 @@ namespace AnotherReplayReader
private async void OnAIAnalyzeClick(object sender, RoutedEventArgs e)
{
- var cached = _cached;
- if (cached is null)
+ if (_model.Replay is not { } replay)
+ {
+ MessageBox.Show(this, "请先选择录像");
+ return;
+ }
+ if (_cached is not { } cached || _cachedPrefixSums is not { } cachedPrefixSums)
{
MessageBox.Show(this, "请先生成文本");
return;
}
- _aiStartTime = DateTimeOffset.MaxValue;
- _elapsedTimeTextBlock.Text = "";
- _contentCountTextBlock.Text = "";
- _rateTextBlock.Text = "";
- _tokensDetailsTextBlock.Text = "";
- _extraStatusTextBlock.Text = "";
-
- _aiTextBox.Clear();
- _aiReasoningTextBox.Clear();
-
- var pending = new ConcurrentQueue();
- var emaSpeedCalculator = new AIAnalyzeUI.EmaSpeed();
- var totalCharacters = 0;
-
- void TimerUpdateStatus(object sender, EventArgs ea)
+ var context = _aiSettings.GetCurrentContext();
+ if (context == null)
{
- if (_aiStartTime == DateTimeOffset.MaxValue)
- {
- _elapsedTimeTextBlock.Text = "";
- return;
- }
- var buffer = new Dictionary
- {
- [AIAnalyze.AIChunkType.Content] = (_aiTextBox, new StringBuilder()),
- [AIAnalyze.AIChunkType.Reasoning] = (_aiReasoningTextBox, new StringBuilder()),
- };
- while (pending.TryDequeue(out var result))
- {
- var (delta, timestamp, isExtra) = result;
- buffer[delta.Type].Text.Append(delta.Text);
- if (!isExtra)
- {
- totalCharacters += delta.Text.Length;
- }
- emaSpeedCalculator.ProcessEvent(result);
- }
- var now = DateTimeOffset.UtcNow;
- var elapsed = now - _aiStartTime;
- _elapsedTimeTextBlock.Text = $"{elapsed:mm\\:ss}";
- _contentCountTextBlock.Text = $"内容字数: {totalCharacters}";
-
- var display = emaSpeedCalculator.GetDisplaySpeed(now);
- _rateTextBlock.Text = $"{display:0.00} 字/秒;平均{totalCharacters / elapsed.TotalSeconds:0.00} 字/秒";
-
- foreach (var kv in buffer)
- {
- var (target, text) = kv.Value;
- if (text.Length > 0)
- {
- target.AppendText(text.ToString());
- text.Clear();
- }
- }
+ MessageBox.Show("请先在“AI 设置”页配置提供商并选择模型");
+ return;
}
+ _aiPanel.GetRequestContext = _aiSettings.GetCurrentContext!;
- var timer = new DispatcherTimer
- {
- Interval = TimeSpan.FromSeconds(0.1),
- };
- timer.Tick += TimerUpdateStatus;
- timer.Start();
- try
- {
- await LaunchAIAnalyze(cached, _cachedPrefixSums, pending.Enqueue);
- TimerUpdateStatus(this, EventArgs.Empty);
- }
- catch (OperationCanceledException)
- {
- MessageBox.Show(this, "AI分析已取消");
- }
- catch (Exception ex)
- {
- MessageBox.Show(this, $"AI分析失败: {ex}");
- }
- finally
- {
- _aiStartTime = DateTimeOffset.MaxValue;
- timer.Stop();
- }
- }
-
- private async Task LaunchAIAnalyze(
- string replayData,
- AIAnalyze.TimeIndexedPrefixSums eventCounts,
- Action newContent
- )
- {
- AIAnalyzeUI.Debug.Clear();
- void AddExtraContent(string message)
- {
- var delta = new AIAnalyze.AIChunk
- {
- Type = AIAnalyze.AIChunkType.Reasoning,
- Text = message,
- };
- newContent(new(Delta: delta, TimeStamp: null, IsExtra: true));
- delta.Type = AIAnalyze.AIChunkType.Content;
- newContent(new(Delta: delta, TimeStamp: null, IsExtra: true));
- }
-
- var analyzer = new AIAnalyze("https://integrate.api.nvidia.com/v1/", "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");
- // var analyzer = new AIAnalyze("https://api.deepseek.com", "sk-a6ffa8e74bfc419bbb4722b5d4c79907");
- var deepSeekExtraParams = new Dictionary
- {
- ["model"] = "deepseek-ai/deepseek-v4-flash", // nvidia
- // ["model"] = "nvidia/nemotron-3-super-120b-a12b", "nvidia/nemotron-3-nano-30b-a3b" // slow
- // ["model"] = "nvidia/nemotron-3-nano-30b-a3b", // not smark
- // ["model"] = "deepseek-v4-flash", // deepseek official
-
- ["temperature"] = 0.75,
- ["top_p"] = 0.95,
- ["max_tokens"] = 16384,
- ["chat_template_kwargs"] = new
- {
- thinking = true,
- reasoning_effort = "high"
- },
- ["reasoning_effort"] = "high",
- ["thinking"] = new { type = "enabled" }
- };
- var systemPrompt = AIAnalyze.GetSystemPrompt(_model.Replay ?? throw new InvalidOperationException(), _model.Players);
- var userPrompt = AIAnalyze.BuildUserPrompt(_model.Mod, _model.Players, replayData, out var userPromptPrefix);
- AddExtraContent($"--- // 输入\r\n{userPromptPrefix}[输入:操作信息流水账]\r\n---\r\n");
- #region info
- var (systemPromptSize, systemPromptTokenCount) = AIAnalyze.EstimateTokenCount(systemPrompt);
- var (userPromptSize, userPromptTokenCount) = AIAnalyze.EstimateTokenCount(userPrompt);
- var totalPromptSize = systemPromptSize + userPromptSize;
- var totalPromptTokenCount = systemPromptTokenCount + userPromptTokenCount;
- var estimateText = $"系统提示大小: {systemPromptSize / 1024.0:0.00}KiB,估计Token数: {systemPromptTokenCount / 1000.0:0.00}K\r\n" +
- $"用户提示大小: {userPromptSize / 1024.0:0.00} KiB,估计Token数: {userPromptTokenCount / 1000.0:0.00} K\r\n" +
- $"总大小: {totalPromptSize / 1024.0:0.00} KiB,估计总Token数: {totalPromptTokenCount / 1000.0:0.00} K\r\n";
- MessageBox.Show(this, estimateText, "提示", MessageBoxButton.OK, MessageBoxImage.Information);
- #endregion
- _extraStatusTextBlock.Text = "AI正在了解录像……";
- _aiStartTime = DateTimeOffset.UtcNow;
- var firstReader = AIAnalyzeUI.BuildAIChunkReader(newContent);
- var result = await Task.Run(() => analyzer.AnalyzeAsync(
- systemPrompt,
- userPrompt,
- deepSeekExtraParams,
- firstReader,
- _cancellation.Token
- ));
- _tokensDetailsTextBlock.Text = $"Token: {result.TotalTokens}(输入{result.PromptTokens})";
-
- AddExtraContent("\r\n--- // 分段大小\r\n");
- foreach (var (Start, End, Description) in result.Segments)
- {
- var count = eventCounts.Query(Start, End);
- AddExtraContent($"[{Start:mm\\:ss\\.ff} - {End:mm\\:ss\\.ff}],事件数: {count}\r\n");
- }
-
- AddExtraContent("\r\n--- // 开始分段分析\r\n");
- while (result.CurrentSegment < result.Segments.Count)
- {
- var (Start, End, Description) = result.Segments[result.CurrentSegment];
- var currentSegmentName = $"{result.CurrentSegment + 1}";
- _extraStatusTextBlock.Text = $"AI正在分析录像{currentSegmentName}/{result.Segments.Count}";
- var segmentReader = AIAnalyzeUI.BuildAIChunkReader(newContent);
- var eventCount = eventCounts.Query(Start, End);
- var segmentUserPrompt = AIAnalyze.BuildSegmentUserPrompt(result.Segments, result.CurrentSegment, eventCount);
- AddExtraContent($"--- // 输入\r\n{segmentUserPrompt}\r\n---\r\n");
- result = await Task.Run(() => analyzer.ContinueAnalyzeAsync(
- segmentReader,
- segmentUserPrompt,
- _cancellation.Token
- ));
- _tokensDetailsTextBlock.Text = $"Token: {result.TotalTokens}(输入{result.PromptTokens})";
- AddExtraContent($"\r\n--- // 第{currentSegmentName}段已分析完毕\r\n");
- }
- var totalEvents = eventCounts.GetTotal();
- var finalUserPrompt = AIAnalyze.BuildFinalUserPrompt(totalEvents);
- AddExtraContent($"--- // 输入\r\n{finalUserPrompt}\r\n---\r\n");
- _extraStatusTextBlock.Text = $"AI正在分析录像总结";
- result = await Task.Run(() => analyzer.FinishAnalyzeAsync(
- AIAnalyzeUI.BuildAIChunkReader(newContent),
- finalUserPrompt,
- _cancellation.Token
- ));
- _tokensDetailsTextBlock.Text = $"Token: {result.TotalTokens}(输入{result.PromptTokens})";
- AddExtraContent($"\r\n--- // 录像分析完毕\r\n");
+ await _aiPanel.StartAnalysisAsync(replay, _model.Players, cached, cachedPrefixSums, _cancellation.Token);
}
}
}
diff --git a/ReplayFile/RA3Commands.cs b/ReplayFile/RA3Commands.cs
index d7ce2f2..ad4be37 100644
--- a/ReplayFile/RA3Commands.cs
+++ b/ReplayFile/RA3Commands.cs
@@ -85,6 +85,8 @@ namespace AnotherReplayReader.ReplayFile
[0x1FB] = "选择编队", // 507
[0x1FC] = "将编队加入选择", // 508
[0x1FD] = "(未知指令 0x1FD)", // 509
+ // 0x1FE: int special power id; int 0 unknown; int unit id count;
+ // unit ids; unit id 0;
[0x1FE] = "释放特殊能力(无目标)", // 510
[0x1FF] = "释放特殊能力(指定位置)", // 511
[0x200] = "释放特殊能力(指定位置和角度)", // 512
diff --git a/Utils/AIAnalyze.cs b/Utils/AIAnalyze.cs
index 14dc1f9..a529744 100644
--- a/Utils/AIAnalyze.cs
+++ b/Utils/AIAnalyze.cs
@@ -2,7 +2,6 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
-using System.Diagnostics.Tracing;
using System.IO;
using System.Linq;
using System.Net.Http;
@@ -18,133 +17,6 @@ namespace AnotherReplayReader.Utils
{
internal sealed class AIAnalyze
{
- public enum AIChunkType
- {
- Reasoning,
- Content,
- Json
- }
-
- public struct AIChunk
- {
- public AIChunkType Type;
- public string Text;
- }
-
- public record Segment(TimeSpan Start, TimeSpan End, string Description);
-
- public struct Result
- {
- public string Response;
- public List Segments;
- public int CurrentSegment;
- public int? PromptTokens;
- public int? CompletionTokens;
- public int? TotalTokens;
- public int? ReasoningTokens;
- }
-
- public record TimeIndexedPrefixSums(List Times, List PrefixSums)
- {
- public void Add(TimeSpan time, int value)
- {
- if (Times.Count > 0 && time < Times.Last())
- {
- throw new ArgumentException("Time must be added in non-decreasing order.");
- }
- Times.Add(time);
- PrefixSums.Add((PrefixSums.LastOrDefault()) + value);
- }
-
- public int Query(TimeSpan start, TimeSpan end)
- {
- var times = Times;
- var prefix = PrefixSums;
-
- int startIndex = LowerBound(times, start);
- int endIndex = UpperBound(times, end);
-
- if (startIndex >= times.Count || endIndex < 0 || startIndex > endIndex)
- {
- return 0;
- }
-
- int result = prefix[endIndex];
-
- if (startIndex > 0)
- {
- result -= prefix[startIndex - 1];
- }
-
- return result;
- }
-
- public int GetTotal()
- {
- return PrefixSums.LastOrDefault();
- }
-
- public static int LowerBound(List arr, TimeSpan target)
- {
- int left = 0, right = arr.Count;
-
- while (left < right)
- {
- int mid = left + (right - left) / 2;
-
- if (arr[mid] < target)
- {
- left = mid + 1;
- }
- else
- {
- right = mid;
- }
- }
-
- return left;
- }
-
- public static int UpperBound(List arr, TimeSpan target)
- {
- int left = 0, right = arr.Count;
-
- while (left < right)
- {
- int mid = left + (right - left) / 2;
-
- if (arr[mid] <= target)
- {
- left = mid + 1;
- }
- else
- {
- right = mid;
- }
- }
-
- return left - 1;
- }
- }
-
- private readonly HttpClient _http;
- private readonly List