wip
This commit is contained in:
+255
-271
@@ -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,也用于 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)
|
||||
{
|
||||
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
@@ -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