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)