wip
This commit is contained in:
@@ -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>
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -39,88 +39,11 @@
|
||||
UndoLimit="0"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Auto" />
|
||||
</TabItem>
|
||||
<TabItem x:Name="_aiTab"
|
||||
Header="AI分析结果">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<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 Header="AI 分析结果">
|
||||
<local:AIChatPanel x:Name="_aiPanel"/>
|
||||
</TabItem>
|
||||
<TabItem Header="AI 设置">
|
||||
<local:AIProviderSettingsControl x:Name="_aiSettings"/>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
+14
-182
@@ -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<AIAnalyzeUI.AIAnalyzeProgressData>();
|
||||
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 = "";
|
||||
MessageBox.Show("请先在“AI 设置”页配置提供商并选择模型");
|
||||
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}";
|
||||
_aiPanel.GetRequestContext = _aiSettings.GetCurrentContext!;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<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");
|
||||
await _aiPanel.StartAnalysisAsync(replay, _model.Players, cached, cachedPrefixSums, _cancellation.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+206
-222
@@ -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<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)
|
||||
{
|
||||
|
||||
@@ -208,6 +80,7 @@ namespace AnotherReplayReader.Utils
|
||||
- 值得列出的、值得反复确认的运营类操作信息:开始建造、摆放建筑、出售建筑
|
||||
- **不是**运营类操作信息:重新选择单位、创建编队、选择编队、移动、攻击等。
|
||||
- 当你在思考时:你可以首先从数量较少的运营类操作信息开始,然后找到可能与其相关的其他操作信息,综合进行推理。不要直接按照时间线列出所有操作信息。
|
||||
- 也可以重点关注PlayerTech、英雄、工程师
|
||||
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
|
||||
- 输出:该阶段的各个主要事件,以及你的推理和发现
|
||||
|
||||
@@ -676,7 +549,7 @@ PlayerA: 开始出兵
|
||||
[MOD:NO:CORONA]
|
||||
- 高科(AlliedTechStructure):解锁超级武器
|
||||
盟军基础常用单位:
|
||||
- 矿车(AlliedMiner):无武装,两栖,可通过SpecialPower_UnpackReplaceSelf在陆地或水上展开变成指挥中心。由于矿场自带矿车,玩家一般不需要额外生产矿车,除非:矿车被摧毁需要补充,或者玩家想要让矿车展开成指挥中心用于基地扩张
|
||||
- 矿车(AlliedMiner):无武装,两栖,可通过SpecialPower_UnpackReplaceSelf在陆地或水上展开变成指挥中心。矿车可由矿场、重工和船厂生产。由于矿场自带矿车,玩家一般不需要额外生产矿车,除非:矿车被摧毁需要补充,或者玩家想要让矿车展开成指挥中心用于基地扩张
|
||||
- 狗(AlliedScoutInfantry):侦察单位,两栖,非常脆弱,只能攻击步兵,吼叫技能(SpecialPower_Bark)可以AOE瘫痪敌方步兵。由于两栖特性,玩家可能利用它去绕海侦察。绕海侦察不一定会导致战斗,因为狗无法攻击载具和建筑而且非常脆弱,但它能够提供视野和侦察信息。
|
||||
- 维和步兵(AlliedAntiInfantryInfantry):基础反步兵单位,数值和造价都偏高,可以抗线,可以掩护其他脆弱的单位,可以在霰弹枪和防暴盾牌之间切换(SpecialPower_ToggleRiotShield)
|
||||
- 标枪兵(AlliedAntiVehicleInfantry):反装甲以及防空单位,无法反步兵且较为脆弱,但假如数量多可以成为输出主力,激光制导(SpecialPower_RadarLock)可以大幅提高输出
|
||||
@@ -1059,7 +932,7 @@ PlayerA: 开始出兵
|
||||
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)
|
||||
{
|
||||
@@ -1145,40 +1018,81 @@ PlayerA: 开始出兵
|
||||
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(
|
||||
string instruction,
|
||||
string text,
|
||||
Dictionary<string, object> extraParams,
|
||||
AiRequestContext requestContext,
|
||||
Action<AIChunk> onChunk,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var kv in extraParams)
|
||||
{
|
||||
_state[kv.Key] = kv.Value;
|
||||
}
|
||||
_state["messages"] = _messages;
|
||||
_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 inputState = State.Initial
|
||||
.AppendNewMessage("system", instruction)
|
||||
.AppendNewMessage("user", text);
|
||||
var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
|
||||
var resultState = result.State;
|
||||
|
||||
var splitted = result.Response.Split('\n').ToList();
|
||||
var titleIndex = splitted.FindIndex(l => l.Contains("[分段列表]"));
|
||||
@@ -1186,8 +1100,7 @@ PlayerA: 开始出兵
|
||||
{
|
||||
throw new Exception("AI分析失败");
|
||||
}
|
||||
_segments.Clear();
|
||||
_currentSegment = 0;
|
||||
|
||||
// regex match two timespan in "[0:00.0]~[0:55.4]"
|
||||
var timeSpanRegex = new Regex(@"\[([^]]+)\]~\[([^]]+)\]");
|
||||
for (var i = titleIndex + 1; i < splitted.Count; ++i)
|
||||
@@ -1201,99 +1114,103 @@ PlayerA: 开始出兵
|
||||
var start = ParseAITimeSpan(startTimeText);
|
||||
var end = ParseAITimeSpan(endTimeText);
|
||||
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.CurrentSegment = _currentSegment;
|
||||
result.State = resultState with { CurrentSegment = 0 };
|
||||
_state = result.State;
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<Result> ContinueAnalyzeAsync(
|
||||
Action<AIChunk> onChunk,
|
||||
string instruction,
|
||||
AiRequestContext requestContext,
|
||||
Action<AIChunk> onChunk,
|
||||
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.");
|
||||
}
|
||||
var inputState = _state.AppendNewMessage("user", instruction);
|
||||
var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
|
||||
|
||||
_messages.Add(new
|
||||
{
|
||||
role = "user",
|
||||
content = instruction
|
||||
});
|
||||
|
||||
var result = await DoRequest(onChunk, cancellationToken);
|
||||
|
||||
++_currentSegment;
|
||||
|
||||
result.Segments = _segments;
|
||||
result.CurrentSegment = _currentSegment;
|
||||
result.State = result.State with { CurrentSegment = _state.CurrentSegment + 1 };
|
||||
_state = result.State;
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<Result> FinishAnalyzeAsync(
|
||||
Action<AIChunk> onChunk,
|
||||
string instruction,
|
||||
AiRequestContext requestContext,
|
||||
Action<AIChunk> onChunk,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_currentSegment != _segments.Count)
|
||||
if (_state.CurrentSegment != _state.Segments.Count)
|
||||
{
|
||||
throw new InvalidOperationException("Current segment index is out of range.");
|
||||
}
|
||||
|
||||
_messages.Add(new
|
||||
{
|
||||
role = "user",
|
||||
content = instruction
|
||||
});
|
||||
var inputState = _state.AppendNewMessage("user", instruction);
|
||||
var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
|
||||
|
||||
var result = await DoRequest(onChunk, cancellationToken);
|
||||
|
||||
|
||||
result.Segments = _segments;
|
||||
result.CurrentSegment = _currentSegment;
|
||||
_state = result.State;
|
||||
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");
|
||||
using var response = await _http.SendAsync(
|
||||
|
||||
using var response = await http.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var reader = new StreamReader(stream);
|
||||
using var responseStream = await response.Content.ReadAsStreamAsync();
|
||||
using var reader = new StreamReader(responseStream);
|
||||
|
||||
var fullBuilder = new StringBuilder();
|
||||
var result = new Result();
|
||||
|
||||
// 根据模式分别读取响应
|
||||
// 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)
|
||||
{
|
||||
isStream = contentType.Equals("text/event-stream", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
if (isStream)
|
||||
{
|
||||
while (!reader.EndOfStream)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var line = await reader.ReadLineAsync();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.StartsWith("data: "))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var data = line.Substring(6);
|
||||
|
||||
if (data == "[DONE]")
|
||||
{
|
||||
break;
|
||||
@@ -1306,6 +1223,39 @@ PlayerA: 开始出兵
|
||||
});
|
||||
|
||||
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,也用于 non‑stream 的完整响应)
|
||||
/// </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)
|
||||
@@ -1316,28 +1266,59 @@ PlayerA: 开始出兵
|
||||
}
|
||||
return null;
|
||||
}
|
||||
result.PromptTokens = GetIntegerProperty(usage, "prompt_tokens");
|
||||
result.TotalTokens = GetIntegerProperty(usage, "total_tokens");
|
||||
result.CompletionTokens = GetIntegerProperty(usage, "completion_tokens");
|
||||
result.ReasoningTokens = GetIntegerProperty(usage, "reasoning_tokens");
|
||||
|
||||
result.PromptTokens = GetIntegerProperty(usage, "prompt_tokens") ?? result.PromptTokens;
|
||||
result.TotalTokens = GetIntegerProperty(usage, "total_tokens") ?? result.TotalTokens;
|
||||
result.CompletionTokens = GetIntegerProperty(usage, "completion_tokens") ?? result.CompletionTokens;
|
||||
result.ReasoningTokens = GetIntegerProperty(usage, "reasoning_tokens") ?? result.ReasoningTokens;
|
||||
}
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("choices", out var choices)
|
||||
|| choices.ValueKind != JsonValueKind.Array
|
||||
|| choices.GetArrayLength() == 0)
|
||||
// 提取 usage(如果存在)
|
||||
if (doc.RootElement.TryGetProperty("error", out var error) && usage.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
if (error.TryGetProperty("message", out var message))
|
||||
{
|
||||
if (message.GetString() is string errorMessage && !string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
onChunk?.Invoke(new AIChunk
|
||||
{
|
||||
Type = AIChunkType.Error,
|
||||
Text = errorMessage
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var delta = choices[0].GetProperty("delta");
|
||||
// ===== content =====
|
||||
if (delta.TryGetProperty("content", out var content))
|
||||
// 提取内容
|
||||
if (doc.RootElement.TryGetProperty("choices", out var choices)
|
||||
&& choices.ValueKind == JsonValueKind.Array
|
||||
&& choices.GetArrayLength() > 0)
|
||||
{
|
||||
var choice = choices[0];
|
||||
// stream 模式使用 delta,非 stream 模式使用 message
|
||||
var contentObj = isStream
|
||||
? choice.GetProperty("delta")
|
||||
: choice.GetProperty("message");
|
||||
|
||||
ExtractContentFromObject(contentObj, fullBuilder, onChunk);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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,
|
||||
@@ -1346,8 +1327,8 @@ PlayerA: 开始出兵
|
||||
}
|
||||
}
|
||||
|
||||
// ===== reasoning (optional, DeepSeek / some models) =====
|
||||
if (delta.TryGetProperty("reasoning_content", out var reasoning))
|
||||
// 推理内容(可选,如 DeepSeek 等模型)
|
||||
if (contentObj.TryGetProperty("reasoning_content", out var reasoning))
|
||||
{
|
||||
var text = reasoning.GetString();
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
@@ -1361,16 +1342,19 @@ PlayerA: 开始出兵
|
||||
}
|
||||
}
|
||||
|
||||
var resultText = fullBuilder.ToString();
|
||||
|
||||
_messages.Add(new
|
||||
private static Dictionary<string, object> ProcessRequestParams(
|
||||
State state,
|
||||
Dictionary<string, object> inputRequestParams)
|
||||
{
|
||||
role = "assistant",
|
||||
content = resultText
|
||||
});
|
||||
|
||||
result.Response = resultText;
|
||||
return result;
|
||||
return new Dictionary<string, object>(inputRequestParams)
|
||||
{
|
||||
["messages"] = state.Messages.ToArray(),
|
||||
["stream"] = true,
|
||||
["stream_options"] = new
|
||||
{
|
||||
include_usage = true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static TimeSpan ParseAITimeSpan(string input)
|
||||
|
||||
+11
-15
@@ -14,24 +14,20 @@ namespace AnotherReplayReader.Utils
|
||||
|
||||
public class EmaSpeed
|
||||
{
|
||||
const double Tau = 5;
|
||||
const double Tau = 2;
|
||||
|
||||
private DateTimeOffset lastEventTime = DateTimeOffset.UtcNow;
|
||||
private DateTimeOffset timeSinceLastSpeedMeasure = DateTimeOffset.UtcNow;
|
||||
private int bufferedCharactersSinceLastSpeedMeasure = 0;
|
||||
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)
|
||||
{
|
||||
lastEventTime = timestamp;
|
||||
}
|
||||
bufferedCharactersSinceLastSpeedMeasure += data.Delta.Text.Length;
|
||||
bufferedCharactersSinceLastSpeedMeasure += textLength;
|
||||
}
|
||||
|
||||
public double GetDisplaySpeed(DateTimeOffset now)
|
||||
@@ -42,7 +38,7 @@ namespace AnotherReplayReader.Utils
|
||||
double instant = 0;
|
||||
double dt = (now - timeSinceLastSpeedMeasure).TotalSeconds;
|
||||
|
||||
if (bufferedCharactersSinceLastSpeedMeasure > 0 && dt > 0.05)
|
||||
if (/*bufferedCharactersSinceLastSpeedMeasure > 0 && */dt > 0.05)
|
||||
{
|
||||
instant = bufferedCharactersSinceLastSpeedMeasure / dt;
|
||||
|
||||
@@ -64,11 +60,11 @@ namespace AnotherReplayReader.Utils
|
||||
double idle = (now - lastEventTime).TotalSeconds;
|
||||
double display = emaSpeed;
|
||||
|
||||
if (idle > 0.5)
|
||||
{
|
||||
double decay = Math.Exp(-(idle - 0.5) / Tau);
|
||||
display *= decay;
|
||||
}
|
||||
//if (idle > 0.5)
|
||||
//{
|
||||
// double decay = Math.Exp(-(idle - 0.5) / Tau);
|
||||
// display *= decay;
|
||||
//}
|
||||
|
||||
return display;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user