This commit is contained in:
2026-07-06 13:48:26 +02:00
parent 241dc2b987
commit 2ada187ae0
11 changed files with 2216 additions and 551 deletions
+149
View File
@@ -0,0 +1,149 @@
<UserControl x:Class="AnotherReplayReader.AIChatPanel"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignHeight="350" d:DesignWidth="700">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 工具栏 -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="5">
<Button x:Name="_cancelButton"
Content="取消"
Click="OnCancelClick"
Visibility="Collapsed"
Margin="0,0,10,0"/>
<Button x:Name="_retryButton"
Content="重试当前段"
Click="OnRetryClick"
Visibility="Collapsed"
Margin="0,0,10,0"/>
<Button x:Name="_abortButton"
Content="放弃"
Click="OnAbortClick"
Visibility="Collapsed"
Margin="0,0,10,0"/>
<Separator Margin="5,0"/>
<TextBlock x:Name="_phaseText"
VerticalAlignment="Center"
FontWeight="Bold"
Margin="0,0,15,0"/>
<TextBlock x:Name="_timeText"
VerticalAlignment="Center"
Margin="0,0,15,0"/>
</StackPanel>
<!-- 主显示区 -->
<FlowDocumentScrollViewer x:Name="_outputViewer"
Grid.Row="1"
VerticalScrollBarVisibility="Auto"
Margin="5,0">
<FlowDocument>
<Paragraph>
<Run Text="AI 分析结果将在此显示..." />
</Paragraph>
</FlowDocument>
</FlowDocumentScrollViewer>
<!-- 状态栏 -->
<StatusBar Grid.Row="2">
<StatusBarItem HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch">
<Grid VerticalAlignment="Stretch">
<Grid.ColumnDefinitions>
<!-- Progress takes ALL remaining space -->
<ColumnDefinition Width="3*" />
<!-- separators + fixed slots -->
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="3*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="6*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="7*"/>
</Grid.ColumnDefinitions>
<!-- ===================== -->
<!-- 1. CHAR PROGRESS -->
<!-- ===================== -->
<TextBlock x:Name="_charProgressText"
Grid.Column="0"
VerticalAlignment="Center"
Margin="6,0"
TextTrimming="CharacterEllipsis"/>
<!-- separator 1 -->
<Border Grid.Column="1"
Width="1"
Margin="4,2"
Background="#80000000"
VerticalAlignment="Stretch"/>
<!-- instant speed -->
<TextBlock x:Name="_instantSpeedText"
Grid.Column="2"
TextAlignment="Right"
VerticalAlignment="Center"
HorizontalAlignment="Right" />
<!-- separator 2 -->
<Border Grid.Column="3"
Width="1"
Margin="4,2"
Background="#80000000"
VerticalAlignment="Stretch"/>
<!-- avg speed -->
<TextBlock x:Name="_avgSpeedText"
Grid.Column="4"
TextAlignment="Right"
VerticalAlignment="Center"
HorizontalAlignment="Right" />
<!-- separator 3 -->
<Border Grid.Column="5"
Width="1"
Margin="4,2"
Background="#80000000"
VerticalAlignment="Stretch"/>
<!-- current tokens -->
<TextBlock x:Name="_currentTokensText"
Grid.Column="6"
VerticalAlignment="Center"
HorizontalAlignment="Center" />
<!-- separator 4 -->
<Border Grid.Column="7"
Width="1"
Margin="4,2"
Background="#80000000"
VerticalAlignment="Stretch"/>
<!-- conversation tokens -->
<TextBlock x:Name="_conversationTokensText"
Grid.Column="8"
TextAlignment="Right"
VerticalAlignment="Center"
HorizontalAlignment="Right" />
</Grid>
</StatusBarItem>
</StatusBar>
</Grid>
</UserControl>
+834
View File
@@ -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}";
}
}
}
+131
View File
@@ -0,0 +1,131 @@
<UserControl x:Class="AnotherReplayReader.AIProviderSettingsControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignHeight="350" d:DesignWidth="650">
<Grid Margin="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 左侧:Provider 列表 -->
<Grid Grid.Column="0">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ListBox x:Name="_providerListBox"
Grid.Row="0"
DisplayMemberPath="Name"
SelectionChanged="OnProviderSelectionChanged"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="新增" Click="OnAddProviderClick" Margin="2"/>
<Button Content="删除" Click="OnRemoveProviderClick" Margin="2"/>
</StackPanel>
</Grid>
<!-- 右侧:Provider 编辑 + 模型管理 -->
<Grid Grid.Column="1" Margin="10,0,0,0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Provider 基本信息 -->
<Grid Grid.Row="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Label Grid.Row="0" Grid.Column="0" Content="名称"/>
<TextBox x:Name="_providerNameBox" Grid.Row="0" Grid.Column="1"/>
<Label Grid.Row="1" Grid.Column="0" Content="Base URL"/>
<TextBox x:Name="_providerUrlBox" Grid.Row="1" Grid.Column="1"/>
<Label Grid.Row="2" Grid.Column="0" Content="API Key"/>
<PasswordBox x:Name="_providerKeyBox" Grid.Row="2" Grid.Column="1"/>
<StackPanel Grid.Row="3" Grid.ColumnSpan="2" Orientation="Horizontal" Margin="0,5">
<Label Content="Temperature" VerticalAlignment="Center"/>
<TextBox x:Name="_temperatureBox" Width="50" Margin="2"/>
<Label Content="TopP" VerticalAlignment="Center" Margin="10,0,0,0"/>
<TextBox x:Name="_topPBox" Width="50" Margin="2"/>
<Label Content="MaxTokens" VerticalAlignment="Center" Margin="10,0,0,0"/>
<TextBox x:Name="_maxTokensBox" Width="60" Margin="2"/>
<Button Content="应用" Click="OnApplyProviderClick" Margin="10,0,0,0"/>
</StackPanel>
</Grid>
<!-- 模型管理区域 -->
<GroupBox Header="模型" Grid.Row="1" Margin="0,5,0,0">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,5">
<ComboBox x:Name="_modelComboBox"
Width="200"
DisplayMemberPath="DisplayText"
SelectionChanged="OnModelSelectionChanged"/>
<Button Content="获取模型列表" Click="OnFetchModelsClick" Margin="5,0"/>
<Button Content="添加自定义" Click="OnAddCustomModelClick" Margin="5,0"/>
<Button Content="删除模型" Click="OnRemoveModelClick" Margin="5,0"/>
</StackPanel>
<!-- 模型详情 -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Label Grid.Row="0" Grid.Column="0" Content="模型 ID"/>
<TextBox x:Name="_modelIdBox" Grid.Row="0" Grid.Column="1"/>
<Label Grid.Row="1" Grid.Column="0" Content="上下文长度"/>
<TextBox x:Name="_contextLengthBox" Grid.Row="1" Grid.Column="1"/>
<CheckBox x:Name="_supportsSseCheck"
Grid.Row="2" Grid.Column="1"
Content="支持 SSE 流式输出"
Margin="0,5"/>
<Label Grid.Row="3" Grid.Column="0" Content="高级参数"/>
<TextBox x:Name="_extraParamsBox"
Grid.Row="3" Grid.Column="1"
MinHeight="80"
AcceptsReturn="True"
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
<StackPanel Grid.Row="4" Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="应用模型修改" Click="OnApplyModelClick" Margin="2"/>
<Button Content="从已知模板填充" Click="OnFillFromKnownModelsClick" Margin="2"/>
</StackPanel>
<TextBlock x:Name="_modelStatusText"
Grid.Row="5" Grid.ColumnSpan="2"
Foreground="Gray" Margin="0,5"/>
</Grid>
</DockPanel>
</GroupBox>
</Grid>
</Grid>
</UserControl>
+380
View File
@@ -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<AiProvider>(_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<ModelDisplayItem>(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<string, object>(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<ModelDisplayItem>()
.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<Dictionary<string, object>>(
_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<string, object>(known.ExtraParameters);
_currentModel.IsStream = known.IsStream;
_currentModel.ContextLength = known.ContextLength;
RefreshModelList();
_modelComboBox.SelectedItem = _modelComboBox.Items
.OfType<ModelDisplayItem>()
.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 = "";
}
}
}
+5 -82
View File
@@ -39,88 +39,11 @@
UndoLimit="0" UndoLimit="0"
ScrollViewer.VerticalScrollBarVisibility="Auto" /> ScrollViewer.VerticalScrollBarVisibility="Auto" />
</TabItem> </TabItem>
<TabItem x:Name="_aiTab" <TabItem Header="AI 分析结果">
Header="AI分析结果"> <local:AIChatPanel x:Name="_aiPanel"/>
<Grid> </TabItem>
<Grid.RowDefinitions> <TabItem Header="AI 设置">
<RowDefinition Height="*" /> <local:AIProviderSettingsControl x:Name="_aiSettings"/>
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="60*" />
<ColumnDefinition Width="5" />
<ColumnDefinition Width="40*" />
</Grid.ColumnDefinitions>
<!-- 左侧 -->
<TextBox x:Name="_aiTextBox"
Grid.Row="0"
Grid.Column="0"
TextWrapping="Wrap"
IsReadOnly="True"
IsUndoEnabled="False"
UndoLimit="0"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto"/>
<!-- splitter -->
<GridSplitter Grid.Row="0"
Grid.Column="1"
Width="5"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"/>
<!-- 右侧 -->
<TextBox x:Name="_aiReasoningTextBox"
Grid.Row="0"
Grid.Column="2"
TextWrapping="Wrap"
IsReadOnly="True"
IsUndoEnabled="False"
UndoLimit="0"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto"/>
<StatusBar Grid.Row="1" Grid.ColumnSpan="3">
<StatusBarItem>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80"/>
<ColumnDefinition Width="140"/>
<ColumnDefinition Width="160"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="180"/>
</Grid.ColumnDefinitions>
<!-- elapsed time -->
<TextBlock x:Name="_elapsedTimeTextBlock"
Grid.Column="0"
TextTrimming="CharacterEllipsis"/>
<!-- total content -->
<TextBlock x:Name="_contentCountTextBlock"
Grid.Column="1"
TextTrimming="CharacterEllipsis"/>
<!-- speed -->
<TextBlock x:Name="_rateTextBlock"
Grid.Column="2"
TextTrimming="CharacterEllipsis"/>
<!-- tokens details -->
<TextBlock x:Name="_tokensDetailsTextBlock"
Grid.Column="3"
TextTrimming="CharacterEllipsis"/>
<!-- extra status -->
<TextBlock x:Name="_extraStatusTextBlock"
Grid.Column="4"
TextTrimming="CharacterEllipsis"
HorizontalAlignment="Right"
TextAlignment="Right"/>
</Grid>
</StatusBarItem>
</StatusBar>
</Grid>
</TabItem> </TabItem>
</TabControl> </TabControl>
</DockPanel> </DockPanel>
+15 -183
View File
@@ -16,6 +16,7 @@ using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Threading; using System.Windows.Threading;
using System.Xml.Linq; using System.Xml.Linq;
using static AnotherReplayReader.AIProviderSettingsControl;
namespace AnotherReplayReader namespace AnotherReplayReader
{ {
@@ -76,8 +77,7 @@ namespace AnotherReplayReader
private readonly CancellationTokenSource _cancellation = new(); private readonly CancellationTokenSource _cancellation = new();
private Model _model = new(); private Model _model = new();
private string? _cached; private string? _cached;
private AIAnalyze.TimeIndexedPrefixSums? _cachedPrefixSums; private TimeIndexedPrefixSums? _cachedPrefixSums;
private DateTimeOffset _aiStartTime;
public EventDump() public EventDump()
{ {
@@ -180,7 +180,7 @@ namespace AnotherReplayReader
_tokenUsageLabel.Content = $"大小: {bytesCount / 1024.0:0.00} KiB,估计Token数: {estimatedTokenCount / 1000.0:0.00} K"; _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([], []); prefixSums = new([], []);
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -431,194 +431,26 @@ namespace AnotherReplayReader
private async void OnAIAnalyzeClick(object sender, RoutedEventArgs e) private async void OnAIAnalyzeClick(object sender, RoutedEventArgs e)
{ {
var cached = _cached; if (_model.Replay is not { } replay)
if (cached is null) {
MessageBox.Show(this, "请先选择录像");
return;
}
if (_cached is not { } cached || _cachedPrefixSums is not { } cachedPrefixSums)
{ {
MessageBox.Show(this, "请先生成文本"); MessageBox.Show(this, "请先生成文本");
return; return;
} }
_aiStartTime = DateTimeOffset.MaxValue; var context = _aiSettings.GetCurrentContext();
_elapsedTimeTextBlock.Text = ""; if (context == null)
_contentCountTextBlock.Text = "";
_rateTextBlock.Text = "";
_tokensDetailsTextBlock.Text = "";
_extraStatusTextBlock.Text = "";
_aiTextBox.Clear();
_aiReasoningTextBox.Clear();
var pending = new ConcurrentQueue<AIAnalyzeUI.AIAnalyzeProgressData>();
var emaSpeedCalculator = new AIAnalyzeUI.EmaSpeed();
var totalCharacters = 0;
void TimerUpdateStatus(object sender, EventArgs ea)
{ {
if (_aiStartTime == DateTimeOffset.MaxValue) MessageBox.Show("请先在“AI 设置”页配置提供商并选择模型");
{ return;
_elapsedTimeTextBlock.Text = "";
return;
}
var buffer = new Dictionary<AIAnalyze.AIChunkType, (TextBox Target, StringBuilder Text)>
{
[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();
}
}
} }
_aiPanel.GetRequestContext = _aiSettings.GetCurrentContext!;
var timer = new DispatcherTimer await _aiPanel.StartAnalysisAsync(replay, _model.Players, cached, cachedPrefixSums, _cancellation.Token);
{
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<AIAnalyzeUI.AIAnalyzeProgressData> 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<string, object>
{
["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");
} }
} }
} }
+2
View File
@@ -85,6 +85,8 @@ namespace AnotherReplayReader.ReplayFile
[0x1FB] = "选择编队", // 507 [0x1FB] = "选择编队", // 507
[0x1FC] = "将编队加入选择", // 508 [0x1FC] = "将编队加入选择", // 508
[0x1FD] = "(未知指令 0x1FD", // 509 [0x1FD] = "(未知指令 0x1FD", // 509
// 0x1FE: int special power id; int 0 unknown; int unit id count;
// unit ids; unit id 0;
[0x1FE] = "释放特殊能力(无目标)", // 510 [0x1FE] = "释放特殊能力(无目标)", // 510
[0x1FF] = "释放特殊能力(指定位置)", // 511 [0x1FF] = "释放特殊能力(指定位置)", // 511
[0x200] = "释放特殊能力(指定位置和角度)", // 512 [0x200] = "释放特殊能力(指定位置和角度)", // 512
+255 -271
View File
@@ -2,7 +2,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Diagnostics.Tracing;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
@@ -18,133 +17,6 @@ namespace AnotherReplayReader.Utils
{ {
internal sealed class AIAnalyze 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<Segment> Segments;
public int CurrentSegment;
public int? PromptTokens;
public int? CompletionTokens;
public int? TotalTokens;
public int? ReasoningTokens;
}
public record TimeIndexedPrefixSums(List<TimeSpan> Times, List<int> 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<TimeSpan> 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<TimeSpan> 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<object> _messages = [];
private readonly Dictionary<string, object> _state = [];
private readonly List<Segment> _segments = [];
private int _currentSegment = -1;
public AIAnalyze(string baseUrl, string apiKey)
{
_http = new HttpClient
{
BaseAddress = new Uri(baseUrl),
Timeout = TimeSpan.FromMinutes(5),
};
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", apiKey);
}
public static string GetSystemPrompt(Replay replay, ImmutableSortedDictionary<int, Player> players) public static string GetSystemPrompt(Replay replay, ImmutableSortedDictionary<int, Player> players)
{ {
@@ -208,6 +80,7 @@ namespace AnotherReplayReader.Utils
- 值得列出的、值得反复确认的运营类操作信息:开始建造、摆放建筑、出售建筑 - 值得列出的、值得反复确认的运营类操作信息:开始建造、摆放建筑、出售建筑
- **不是**运营类操作信息:重新选择单位、创建编队、选择编队、移动、攻击等。 - **不是**运营类操作信息:重新选择单位、创建编队、选择编队、移动、攻击等。
- 当你在思考时:你可以首先从数量较少的运营类操作信息开始,然后找到可能与其相关的其他操作信息,综合进行推理。不要直接按照时间线列出所有操作信息。 - 当你在思考时:你可以首先从数量较少的运营类操作信息开始,然后找到可能与其相关的其他操作信息,综合进行推理。不要直接按照时间线列出所有操作信息。
- 也可以重点关注PlayerTech、英雄、工程师
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现 - 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
- 输出:该阶段的各个主要事件,以及你的推理和发现 - 输出:该阶段的各个主要事件,以及你的推理和发现
@@ -676,7 +549,7 @@ PlayerA: 开始出兵
[MOD:NO:CORONA] [MOD:NO:CORONA]
- 高科(AlliedTechStructure):解锁超级武器 - 高科(AlliedTechStructure):解锁超级武器
盟军基础常用单位: 盟军基础常用单位:
- 矿车(AlliedMiner):无武装,两栖,可通过SpecialPower_UnpackReplaceSelf在陆地或水上展开变成指挥中心。由于矿场自带矿车,玩家一般不需要额外生产矿车,除非:矿车被摧毁需要补充,或者玩家想要让矿车展开成指挥中心用于基地扩张 - 矿车(AlliedMiner):无武装,两栖,可通过SpecialPower_UnpackReplaceSelf在陆地或水上展开变成指挥中心。矿车可由矿场、重工和船厂生产。由于矿场自带矿车,玩家一般不需要额外生产矿车,除非:矿车被摧毁需要补充,或者玩家想要让矿车展开成指挥中心用于基地扩张
- 狗(AlliedScoutInfantry):侦察单位,两栖,非常脆弱,只能攻击步兵,吼叫技能(SpecialPower_Bark)可以AOE瘫痪敌方步兵。由于两栖特性,玩家可能利用它去绕海侦察。绕海侦察不一定会导致战斗,因为狗无法攻击载具和建筑而且非常脆弱,但它能够提供视野和侦察信息。 - 狗(AlliedScoutInfantry):侦察单位,两栖,非常脆弱,只能攻击步兵,吼叫技能(SpecialPower_Bark)可以AOE瘫痪敌方步兵。由于两栖特性,玩家可能利用它去绕海侦察。绕海侦察不一定会导致战斗,因为狗无法攻击载具和建筑而且非常脆弱,但它能够提供视野和侦察信息。
- 维和步兵(AlliedAntiInfantryInfantry):基础反步兵单位,数值和造价都偏高,可以抗线,可以掩护其他脆弱的单位,可以在霰弹枪和防暴盾牌之间切换(SpecialPower_ToggleRiotShield) - 维和步兵(AlliedAntiInfantryInfantry):基础反步兵单位,数值和造价都偏高,可以抗线,可以掩护其他脆弱的单位,可以在霰弹枪和防暴盾牌之间切换(SpecialPower_ToggleRiotShield)
- 标枪兵(AlliedAntiVehicleInfantry):反装甲以及防空单位,无法反步兵且较为脆弱,但假如数量多可以成为输出主力,激光制导(SpecialPower_RadarLock)可以大幅提高输出 - 标枪兵(AlliedAntiVehicleInfantry):反装甲以及防空单位,无法反步兵且较为脆弱,但假如数量多可以成为输出主力,激光制导(SpecialPower_RadarLock)可以大幅提高输出
@@ -967,7 +840,7 @@ PlayerA: 开始出兵
} }
continue; continue;
} }
if (modName.Equals(mod.ModName, StringComparison.OrdinalIgnoreCase)) if (modName.Equals(mod.ModName, StringComparison.OrdinalIgnoreCase))
{ {
l = l.Substring(0, startIndex) + content + l.Substring(endIndex + endTag.Length); l = l.Substring(0, startIndex) + content + l.Substring(endIndex + endTag.Length);
@@ -1059,7 +932,7 @@ PlayerA: 开始出兵
return sb.ToString().Replace("\r", ""); return sb.ToString().Replace("\r", "");
} }
public static string BuildSegmentUserPrompt(List<Segment> segments, int currentSegmentIndex, int eventCount) public static string BuildSegmentUserPrompt(IReadOnlyList<Segment> segments, int currentSegmentIndex, int eventCount)
{ {
if (currentSegmentIndex < 0 || currentSegmentIndex >= segments.Count) if (currentSegmentIndex < 0 || currentSegmentIndex >= segments.Count)
{ {
@@ -1145,40 +1018,81 @@ PlayerA: 开始出兵
return result.ToImmutableSortedDictionary(); return result.ToImmutableSortedDictionary();
} }
public enum AIChunkType
{
Reasoning,
Content,
Error,
Json
}
public struct AIChunk
{
public AIChunkType Type;
public string Text;
}
public record Segment(TimeSpan Start, TimeSpan End, string Description);
public record State(ImmutableList<object> Messages, ImmutableList<Segment> Segments, int CurrentSegment)
{
public static State Initial => new(ImmutableList<object>.Empty, ImmutableList<Segment>.Empty, -1);
public State AppendNewMessage(string role, string content)
{
var newMessages = Messages.Add(new
{
role,
content
});
return this with { Messages = newMessages };
}
public State AppendNewSegment(Segment segment)
{
var newSegments = Segments.Add(segment);
return this with { Segments = newSegments };
}
}
public struct Result
{
public string Response;
public State State;
public int? PromptTokens;
public int? CompletionTokens;
public int? TotalTokens;
public int? ReasoningTokens;
}
private readonly HttpClient _http;
private State _state = State.Initial;
public State LastSuccessfulState => _state;
public AIAnalyze()
{
_http = new HttpClient
{
Timeout = TimeSpan.FromMinutes(5),
};
}
public void SetState(State state)
{
_state = state;
}
public async Task<Result> AnalyzeAsync( public async Task<Result> AnalyzeAsync(
string instruction, string instruction,
string text, string text,
Dictionary<string, object> extraParams, AiRequestContext requestContext,
Action<AIChunk> onChunk, Action<AIChunk> onChunk,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
foreach (var kv in extraParams) var inputState = State.Initial
{ .AppendNewMessage("system", instruction)
_state[kv.Key] = kv.Value; .AppendNewMessage("user", text);
} var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
_state["messages"] = _messages; var resultState = result.State;
_state["stream"] = true;
_state["stream_options"] = new
{
include_usage = true
};
_messages.Clear();
_messages.AddRange(
[
new
{
role = "system",
content = instruction
},
new
{
role = "user",
content = text
}
]);
var result = await DoRequest(onChunk, cancellationToken);
var splitted = result.Response.Split('\n').ToList(); var splitted = result.Response.Split('\n').ToList();
var titleIndex = splitted.FindIndex(l => l.Contains("[分段列表]")); var titleIndex = splitted.FindIndex(l => l.Contains("[分段列表]"));
@@ -1186,8 +1100,7 @@ PlayerA: 开始出兵
{ {
throw new Exception("AI分析失败"); throw new Exception("AI分析失败");
} }
_segments.Clear();
_currentSegment = 0;
// regex match two timespan in "[0:00.0]~[0:55.4]" // regex match two timespan in "[0:00.0]~[0:55.4]"
var timeSpanRegex = new Regex(@"\[([^]]+)\]~\[([^]]+)\]"); var timeSpanRegex = new Regex(@"\[([^]]+)\]~\[([^]]+)\]");
for (var i = titleIndex + 1; i < splitted.Count; ++i) for (var i = titleIndex + 1; i < splitted.Count; ++i)
@@ -1201,176 +1114,247 @@ PlayerA: 开始出兵
var start = ParseAITimeSpan(startTimeText); var start = ParseAITimeSpan(startTimeText);
var end = ParseAITimeSpan(endTimeText); var end = ParseAITimeSpan(endTimeText);
var description = line.Substring(match.Index + match.Length).Trim(); var description = line.Substring(match.Index + match.Length).Trim();
_segments.Add(new(start, end, description)); resultState = resultState.AppendNewSegment(new(start, end, description));
} }
} }
result.Segments = _segments; result.State = resultState with { CurrentSegment = 0 };
result.CurrentSegment = _currentSegment; _state = result.State;
return result; return result;
} }
public async Task<Result> ContinueAnalyzeAsync( public async Task<Result> ContinueAnalyzeAsync(
Action<AIChunk> onChunk,
string instruction, string instruction,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (_currentSegment < 0 || _currentSegment >= _segments.Count) if (_state.CurrentSegment < 0 || _state.CurrentSegment >= _state.Segments.Count)
{ {
throw new InvalidOperationException("Current segment index is out of range."); throw new InvalidOperationException("Current segment index is out of range.");
} }
var inputState = _state.AppendNewMessage("user", instruction);
_messages.Add(new var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
{
role = "user",
content = instruction
});
var result = await DoRequest(onChunk, cancellationToken); result.State = result.State with { CurrentSegment = _state.CurrentSegment + 1 };
_state = result.State;
++_currentSegment;
result.Segments = _segments;
result.CurrentSegment = _currentSegment;
return result; return result;
} }
public async Task<Result> FinishAnalyzeAsync( public async Task<Result> FinishAnalyzeAsync(
Action<AIChunk> onChunk,
string instruction, string instruction,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (_currentSegment != _segments.Count) if (_state.CurrentSegment != _state.Segments.Count)
{ {
throw new InvalidOperationException("Current segment index is out of range."); throw new InvalidOperationException("Current segment index is out of range.");
} }
_messages.Add(new var inputState = _state.AppendNewMessage("user", instruction);
{ var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
role = "user",
content = instruction
});
var result = await DoRequest(onChunk, cancellationToken); _state = result.State;
result.Segments = _segments;
result.CurrentSegment = _currentSegment;
return result; return result;
} }
private async Task<Result> DoRequest(Action<AIChunk> onChunk, CancellationToken cancellationToken) private static async Task<Result> DoRequest(
HttpClient http,
State state,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken)
{ {
var inputJson = JsonSerializer.Serialize(_state); var provider = requestContext.Provider;
var requestParams = ProcessRequestParams(state, requestContext.BuildRequestParams());
var isStream = requestContext.Model.IsStream;
var inputJson = JsonSerializer.Serialize(requestParams);
using var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions"); var uri = new Uri(new(provider.BaseUrl.TrimEnd('/') + "/"), "chat/completions");
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", provider.ApiKey);
request.Content = new StringContent(inputJson, Encoding.UTF8, "application/json"); request.Content = new StringContent(inputJson, Encoding.UTF8, "application/json");
using var response = await _http.SendAsync(
using var response = await http.SendAsync(
request, request,
HttpCompletionOption.ResponseHeadersRead, HttpCompletionOption.ResponseHeadersRead,
cancellationToken); cancellationToken);
response.EnsureSuccessStatusCode(); using var responseStream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(responseStream);
using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
var fullBuilder = new StringBuilder(); var fullBuilder = new StringBuilder();
var result = new Result(); var result = new Result();
while (!reader.EndOfStream) // 根据模式分别读取响应
// if response.Content.Headers.ContentType is "text/event-stream", then it's stream mode, otherwise it's non-stream mode
var contentType = response.Content.Headers.ContentType?.MediaType;
if (contentType != null)
{ {
var line = await reader.ReadLineAsync(); isStream = contentType.Equals("text/event-stream", StringComparison.OrdinalIgnoreCase);
}
if (string.IsNullOrWhiteSpace(line)) if (isStream)
{
while (!reader.EndOfStream)
{ {
continue; cancellationToken.ThrowIfCancellationRequested();
} var line = await reader.ReadLineAsync();
if (!line.StartsWith("data: ")) if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var data = line.Substring(6);
if (data == "[DONE]")
{
break;
}
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.Json,
Text = data
});
using var doc = JsonDocument.Parse(data);
if (doc.RootElement.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
{
static int? GetIntegerProperty(JsonElement @object, string field)
{ {
if (@object.TryGetProperty(field, out var value) && value.ValueKind is JsonValueKind.Number) continue;
{
return value.GetInt32();
}
return null;
} }
result.PromptTokens = GetIntegerProperty(usage, "prompt_tokens"); if (!line.StartsWith("data: "))
result.TotalTokens = GetIntegerProperty(usage, "total_tokens");
result.CompletionTokens = GetIntegerProperty(usage, "completion_tokens");
result.ReasoningTokens = GetIntegerProperty(usage, "reasoning_tokens");
}
if (!doc.RootElement.TryGetProperty("choices", out var choices)
|| choices.ValueKind != JsonValueKind.Array
|| choices.GetArrayLength() == 0)
{
continue;
}
var delta = choices[0].GetProperty("delta");
// ===== content =====
if (delta.TryGetProperty("content", out var content))
{
var text = content.GetString();
if (!string.IsNullOrEmpty(text))
{ {
fullBuilder.Append(text); continue;
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.Content,
Text = text
});
} }
var data = line.Substring(6);
if (data == "[DONE]")
{
break;
}
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.Json,
Text = data
});
using var doc = JsonDocument.Parse(data);
ProcessJsonDocument(doc, isStream: true, fullBuilder, result, onChunk);
}
}
else
{
var json = await reader.ReadToEndAsync();
using var doc = JsonDocument.Parse(json);
ProcessJsonDocument(doc, isStream: false, fullBuilder, result, onChunk);
}
response.EnsureSuccessStatusCode();
if (fullBuilder.Length == 0)
{
throw new Exception("AI分析失败,返回内容为空");
}
var resultText = fullBuilder.ToString();
result.State = state.AppendNewMessage("assistant", resultText);
result.Response = resultText;
return result;
}
/// <summary>
/// 处理单个 JSON 响应(既用于 stream 的每个 chunk,也用于 nonstream 的完整响应)
/// </summary>
private static void ProcessJsonDocument(
JsonDocument doc,
bool isStream,
StringBuilder fullBuilder,
Result result,
Action<AIChunk> onChunk)
{
// 提取 usage(如果存在)
if (doc.RootElement.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
{
static int? GetIntegerProperty(JsonElement @object, string field)
{
if (@object.TryGetProperty(field, out var value) && value.ValueKind is JsonValueKind.Number)
{
return value.GetInt32();
}
return null;
} }
// ===== reasoning (optional, DeepSeek / some models) ===== result.PromptTokens = GetIntegerProperty(usage, "prompt_tokens") ?? result.PromptTokens;
if (delta.TryGetProperty("reasoning_content", out var reasoning)) result.TotalTokens = GetIntegerProperty(usage, "total_tokens") ?? result.TotalTokens;
result.CompletionTokens = GetIntegerProperty(usage, "completion_tokens") ?? result.CompletionTokens;
result.ReasoningTokens = GetIntegerProperty(usage, "reasoning_tokens") ?? result.ReasoningTokens;
}
// 提取 usage(如果存在)
if (doc.RootElement.TryGetProperty("error", out var error) && usage.ValueKind == JsonValueKind.Object)
{
if (error.TryGetProperty("message", out var message))
{ {
var text = reasoning.GetString(); if (message.GetString() is string errorMessage && !string.IsNullOrWhiteSpace(errorMessage))
if (!string.IsNullOrEmpty(text))
{ {
onChunk?.Invoke(new AIChunk onChunk?.Invoke(new AIChunk
{ {
Type = AIChunkType.Reasoning, Type = AIChunkType.Error,
Text = text Text = errorMessage
}); });
} }
} }
} }
var resultText = fullBuilder.ToString(); // 提取内容
if (doc.RootElement.TryGetProperty("choices", out var choices)
_messages.Add(new && choices.ValueKind == JsonValueKind.Array
&& choices.GetArrayLength() > 0)
{ {
role = "assistant", var choice = choices[0];
content = resultText // stream 模式使用 delta,非 stream 模式使用 message
}); var contentObj = isStream
? choice.GetProperty("delta")
: choice.GetProperty("message");
result.Response = resultText; ExtractContentFromObject(contentObj, fullBuilder, onChunk);
return result; }
}
/// <summary>
/// 从 delta 或 message 对象中提取 content 和 reasoning_content
/// </summary>
private static void ExtractContentFromObject(
JsonElement contentObj,
StringBuilder fullBuilder,
Action<AIChunk> onChunk)
{
// 普通内容
if (contentObj.TryGetProperty("content", out var content))
{
var text = content.GetString();
if (!string.IsNullOrEmpty(text))
{
fullBuilder.Append(text);
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.Content,
Text = text
});
}
}
// 推理内容(可选,如 DeepSeek 等模型)
if (contentObj.TryGetProperty("reasoning_content", out var reasoning))
{
var text = reasoning.GetString();
if (!string.IsNullOrEmpty(text))
{
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.Reasoning,
Text = text
});
}
}
}
private static Dictionary<string, object> ProcessRequestParams(
State state,
Dictionary<string, object> inputRequestParams)
{
return new Dictionary<string, object>(inputRequestParams)
{
["messages"] = state.Messages.ToArray(),
["stream"] = true,
["stream_options"] = new
{
include_usage = true
}
};
} }
private static TimeSpan ParseAITimeSpan(string input) private static TimeSpan ParseAITimeSpan(string input)
+11 -15
View File
@@ -14,24 +14,20 @@ namespace AnotherReplayReader.Utils
public class EmaSpeed public class EmaSpeed
{ {
const double Tau = 5; const double Tau = 2;
private DateTimeOffset lastEventTime = DateTimeOffset.UtcNow; private DateTimeOffset lastEventTime = DateTimeOffset.UtcNow;
private DateTimeOffset timeSinceLastSpeedMeasure = DateTimeOffset.UtcNow; private DateTimeOffset timeSinceLastSpeedMeasure = DateTimeOffset.UtcNow;
private int bufferedCharactersSinceLastSpeedMeasure = 0; private int bufferedCharactersSinceLastSpeedMeasure = 0;
private double emaSpeed = double.NaN; private double emaSpeed = double.NaN;
public void ProcessEvent(AIAnalyzeProgressData data) public void ProcessEvent(int textLength, DateTimeOffset? eventTime)
{ {
if (data.IsExtra) if (eventTime is { } value)
{ {
return; lastEventTime = value;
} }
if (data.TimeStamp is { } timestamp) bufferedCharactersSinceLastSpeedMeasure += textLength;
{
lastEventTime = timestamp;
}
bufferedCharactersSinceLastSpeedMeasure += data.Delta.Text.Length;
} }
public double GetDisplaySpeed(DateTimeOffset now) public double GetDisplaySpeed(DateTimeOffset now)
@@ -42,7 +38,7 @@ namespace AnotherReplayReader.Utils
double instant = 0; double instant = 0;
double dt = (now - timeSinceLastSpeedMeasure).TotalSeconds; double dt = (now - timeSinceLastSpeedMeasure).TotalSeconds;
if (bufferedCharactersSinceLastSpeedMeasure > 0 && dt > 0.05) if (/*bufferedCharactersSinceLastSpeedMeasure > 0 && */dt > 0.05)
{ {
instant = bufferedCharactersSinceLastSpeedMeasure / dt; instant = bufferedCharactersSinceLastSpeedMeasure / dt;
@@ -64,11 +60,11 @@ namespace AnotherReplayReader.Utils
double idle = (now - lastEventTime).TotalSeconds; double idle = (now - lastEventTime).TotalSeconds;
double display = emaSpeed; double display = emaSpeed;
if (idle > 0.5) //if (idle > 0.5)
{ //{
double decay = Math.Exp(-(idle - 0.5) / Tau); // double decay = Math.Exp(-(idle - 0.5) / Tau);
display *= decay; // display *= decay;
} //}
return display; return display;
} }
+345
View File
@@ -0,0 +1,345 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
namespace AnotherReplayReader
{
/// <summary>
/// 服务端点配置(如 DeepSeek 官方、NVIDIA NIM
/// </summary>
public class AiProvider
{
public string Name { get; set; } = string.Empty;
public string BaseUrl { get; set; } = string.Empty;
public string ApiKey { get; set; } = string.Empty;
public List<AiModel> Models { get; set; } = [];
public double DefaultTemperature { get; set; } = 0.75;
public double DefaultTopP { get; set; } = 0.95;
public int DefaultMaxTokens { get; set; } = 16384;
}
/// <summary>
/// 模型条目
/// </summary>
public class AiModel
{
public string ModelId { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public bool IsStream { get; set; }
public int ContextLength { get; set; } // 0 表示未知
public Dictionary<string, object> ExtraParameters { get; set; } = [];
/// <summary>
/// 构建最终请求参数(合并 Provider 默认值、模型特有参数和运行时覆盖)
/// </summary>
public Dictionary<string, object> BuildRequestParams(
AiProvider provider,
double? temperatureOverride = null,
double? topPOverride = null,
int? maxTokensOverride = null)
{
var parameters = new Dictionary<string, object>
{
["model"] = ModelId,
["temperature"] = temperatureOverride ?? provider.DefaultTemperature,
["top_p"] = topPOverride ?? provider.DefaultTopP,
["max_tokens"] = maxTokensOverride ?? provider.DefaultMaxTokens,
["stream"] = IsStream
};
foreach (var kv in this.ExtraParameters)
{
parameters[kv.Key] = kv.Value;
}
return parameters;
}
}
/// <summary>
/// 每次请求前的动态配置上下文(当前选中的 Provider 和 Model
/// </summary>
public record AiRequestContext(AiProvider Provider, AiModel Model)
{
public Dictionary<string, object> BuildRequestParams(
double? temperatureOverride = null,
double? topPOverride = null,
int? maxTokensOverride = null)
{
return Model.BuildRequestParams(Provider,
temperatureOverride, topPOverride, maxTokensOverride);
}
}
/// <summary>
/// 内置已知模型信息(提供商无关,纯模型参数模板)
/// </summary>
public static class KnownModels
{
public const int SimilarityThreshold = 80;
public static int GetSimilarity(string sourceModelId, string targetModelId)
{
// prefer exact match
if (sourceModelId.Equals(targetModelId, StringComparison.OrdinalIgnoreCase))
{
return 100;
}
// match the part after slash, e.g. "deepseek-ai/deepseek-v4-flash" vs "deepseek-v4-flash"
// if last part matches, return 90
var sourceModelIdLastPart = sourceModelId.Split('/').LastOrDefault() ?? sourceModelId;
var targetModelIdLastPart = targetModelId.Split('/').LastOrDefault() ?? targetModelId;
if (sourceModelIdLastPart.Equals(targetModelIdLastPart, StringComparison.OrdinalIgnoreCase))
{
return 90;
}
return 0;
}
/// <summary>
/// 返回一组已知模型,包含正确的 ExtraParameters。
/// 调用方可按需复制到 Provider 的 Models 列表中。
/// </summary>
public static List<AiModel> GetAll()
{
return
[
// DeepSeek 官方
new()
{
ModelId = "deepseek-v4-flash",
DisplayName = "DeepSeek V4 Flash",
IsStream = true,
ContextLength = 1_000_000,
ExtraParameters = new()
{
["thinking"] = new { type = "enabled" },
["reasoning_effort"] = "high"
}
},
new()
{
ModelId = "deepseek-v4-pro",
DisplayName = "DeepSeek V4 Pro",
IsStream = true,
ContextLength = 1_000_000,
ExtraParameters = new()
{
["thinking"] = new { type = "enabled" },
["reasoning_effort"] = "high"
}
},
// NVIDIA NIM 上的 DeepSeek 模型
new()
{
ModelId = "deepseek-ai/deepseek-v4-flash",
DisplayName = "DeepSeek V4 Flash (NIM)",
IsStream = true,
ContextLength = 1_000_000,
ExtraParameters = new()
{
// ["chat_template_kwargs"] = new { thinking = true },
["thinking"] = new { type = "enabled" },
["reasoning_effort"] = "high",
}
},
new()
{
ModelId = "deepseek-ai/deepseek-v4-pro",
DisplayName = "DeepSeek V4 Pro (NIM)",
IsStream = true,
ContextLength = 1_000_000,
ExtraParameters = new()
{
// ["chat_template_kwargs"] = new { thinking = true },
["thinking"] = new { type = "enabled" },
["reasoning_effort"] = "high",
}
},
// NVIDIA Nemotron
new()
{
ModelId = "nvidia/nemotron-3-super-120b-a12b",
DisplayName = "Nemotron Super 120B (NIM)",
IsStream = true,
ContextLength = 1_000_000,
ExtraParameters = new()
{
["reasoning_budget"] = 16384
}
},
// Minimax
new()
{
ModelId = "minimaxai/minimax-m3",
DisplayName = "MiniMax-M3 (NIM)",
IsStream = false,
ContextLength = 1_000_000,
ExtraParameters = []
},
// Kimi
new()
{
ModelId = "moonshotai/kimi-k2.6",
DisplayName = "Kimi-K2.6 (NIM)",
IsStream = false,
ContextLength = 256_000,
ExtraParameters = []
},
// Google DiffusionGemma
new()
{
ModelId = "google/diffusiongemma-26b-a4b-it",
DisplayName = "DiffusionGemma 26B A4B IT (NIM)",
IsStream = false,
ContextLength = 250_000,
ExtraParameters = new()
{
["chat_template_kwargs"] = new { enable_thinking = true },
}
},
// OpenAI 兼容
new()
{
ModelId = "openai/gpt-oss-120b",
DisplayName = "GPT OSS 120B (NIM)",
IsStream = true,
ContextLength = 128_000,
ExtraParameters = new()
{
["reasoning_effort"] = "medium"
}
}
];
}
}
/// <summary>
/// 从 OpenAI 兼容的 /v1/models 端点获取可用模型 ID 列表
/// </summary>
public static class AiModelFetcher
{
public static async Task<List<string>> FetchModelsAsync(
string baseUrl, string apiKey)
{
baseUrl = baseUrl.TrimEnd('/') + "/";
var models = new List<string>();
using var client = new HttpClient();
var request = new HttpRequestMessage(
HttpMethod.Get, new Uri(new(baseUrl), "models"));
request.Headers.Add("Authorization", $"Bearer {apiKey}");
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
throw new Exception(
$"获取模型列表失败: HTTP {(int)response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("data", out var dataArray))
{
foreach (var item in dataArray.EnumerateArray())
{
if (item.TryGetProperty("id", out var idProp))
{
var id = idProp.GetString();
if (!string.IsNullOrEmpty(id))
{
models.Add(id!);
}
}
}
}
return models;
}
}
/// <summary>
/// 全局 AI 配置(多个 Provider)的持久化管理
/// </summary>
public class AiSettings
{
public List<AiProvider> Providers { get; set; } = [];
// 以下两个不持久化,由 UI 层维护当前选中项
[System.Text.Json.Serialization.JsonIgnore]
public int CurrentProviderIndex { get; set; }
private static readonly string ConfigPath = Path.Combine(
AppContext.BaseDirectory,
"AnotherReplayReader.ai_settings.json");
public static AiSettings Load()
{
try
{
if (File.Exists(ConfigPath))
{
var json = File.ReadAllText(ConfigPath);
var settings = JsonSerializer.Deserialize<AiSettings>(json);
if (settings is { } value && value.Providers.Count > 0)
{
return settings;
}
}
}
catch (Exception ex)
{
// 返回默认配置
Debug.Instance.DebugMessage += $"加载 AI 配置失败: {ex}\r\n";
}
// 返回默认配置:包含两个常用 Provider,各附一个内置模型
var defaults = new AiSettings();
var nimProvider = new AiProvider
{
Name = "NVIDIA NIM",
BaseUrl = "https://integrate.api.nvidia.com/v1",
ApiKey = "",
Models =
[
KnownModels.GetAll().First(m => m.ModelId == "deepseek-ai/deepseek-v4-flash")
]
};
var deepseekProvider = new AiProvider
{
Name = "DeepSeek 官方",
BaseUrl = "https://api.deepseek.com",
ApiKey = "",
Models =
[
KnownModels.GetAll().First(m => m.ModelId == "deepseek-v4-flash")
]
};
defaults.Providers.Add(nimProvider);
defaults.Providers.Add(deepseekProvider);
return defaults;
}
public void Save()
{
var dir = Path.GetDirectoryName(ConfigPath);
if (dir is not null)
{
Directory.CreateDirectory(dir);
}
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(ConfigPath, json);
}
}
}
+89
View File
@@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AnotherReplayReader.Utils
{
public record TimeIndexedPrefixSums(List<TimeSpan> Times, List<int> 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<TimeSpan> 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<TimeSpan> 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;
}
}
}