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
+255 -271
View File
@@ -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)可以大幅提高输出
@@ -967,7 +840,7 @@ PlayerA: 开始出兵
}
continue;
}
if (modName.Equals(mod.ModName, StringComparison.OrdinalIgnoreCase))
{
l = l.Substring(0, startIndex) + content + l.Substring(endIndex + endTag.Length);
@@ -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,176 +1114,247 @@ 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.");
}
_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);
++_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();
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();
if (string.IsNullOrWhiteSpace(line))
isStream = contentType.Equals("text/event-stream", StringComparison.OrdinalIgnoreCase);
}
if (isStream)
{
while (!reader.EndOfStream)
{
continue;
}
cancellationToken.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync();
if (!line.StartsWith("data: "))
{
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 (string.IsNullOrWhiteSpace(line))
{
if (@object.TryGetProperty(field, out var value) && value.ValueKind is JsonValueKind.Number)
{
return value.GetInt32();
}
return null;
continue;
}
result.PromptTokens = GetIntegerProperty(usage, "prompt_tokens");
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))
if (!line.StartsWith("data: "))
{
fullBuilder.Append(text);
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.Content,
Text = text
});
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);
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) =====
if (delta.TryGetProperty("reasoning_content", out var reasoning))
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;
}
// 提取 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 (!string.IsNullOrEmpty(text))
if (message.GetString() is string errorMessage && !string.IsNullOrWhiteSpace(errorMessage))
{
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.Reasoning,
Text = text
Type = AIChunkType.Error,
Text = errorMessage
});
}
}
}
var resultText = fullBuilder.ToString();
_messages.Add(new
// 提取内容
if (doc.RootElement.TryGetProperty("choices", out var choices)
&& choices.ValueKind == JsonValueKind.Array
&& choices.GetArrayLength() > 0)
{
role = "assistant",
content = resultText
});
var choice = choices[0];
// stream 模式使用 delta,非 stream 模式使用 message
var contentObj = isStream
? choice.GetProperty("delta")
: choice.GetProperty("message");
result.Response = resultText;
return result;
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,
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)
+11 -15
View File
@@ -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;
}
+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;
}
}
}