304 lines
13 KiB
C#
304 lines
13 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using Lskj.AgentPet.Host.Core.Configuration;
|
|
using Lskj.AgentPet.Host.Core.Security;
|
|
|
|
namespace Lskj.AgentPet.Host.Core.AstrBot;
|
|
|
|
public sealed record AstrBotStreamEvent(
|
|
string Type,
|
|
JsonElement Data,
|
|
string? ChainType,
|
|
bool Streaming,
|
|
string? RunId,
|
|
string? SessionId);
|
|
|
|
public sealed record AstrBotChatAttachment(
|
|
string AttachmentId,
|
|
string FileName,
|
|
string Type);
|
|
|
|
public sealed record AstrBotChatRequest(
|
|
string Text,
|
|
IReadOnlyList<AstrBotChatAttachment> Attachments)
|
|
{
|
|
/// <summary>
|
|
/// Host-generated, already-sanitized ERP execution evidence. Browser input
|
|
/// can never populate this property.
|
|
/// </summary>
|
|
public string? TrustedContext { get; init; }
|
|
}
|
|
|
|
public interface IAstrBotChatClient
|
|
{
|
|
IAsyncEnumerable<AstrBotStreamEvent> StreamAsync(
|
|
AstrBotChatRequest request,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
public sealed class AstrBotChatClient : IAstrBotChatClient
|
|
{
|
|
private static readonly Regex SafeType = new(
|
|
"^[a-zA-Z0-9_.:-]{1,64}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeAttachmentId = new(
|
|
"^[A-Za-z0-9_-]{8,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeRunId = new(
|
|
"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private readonly HttpClient _httpClient;
|
|
private readonly HostConfiguration _configuration;
|
|
|
|
public AstrBotChatClient(HttpClient httpClient, HostConfiguration configuration)
|
|
{
|
|
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
|
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
|
}
|
|
|
|
public async IAsyncEnumerable<AstrBotStreamEvent> StreamAsync(
|
|
AstrBotChatRequest chatRequest,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(chatRequest);
|
|
string text = (chatRequest.Text ?? string.Empty).Trim();
|
|
IReadOnlyList<AstrBotChatAttachment> attachments =
|
|
chatRequest.Attachments ?? Array.Empty<AstrBotChatAttachment>();
|
|
if (text.Length > 2000 || (text.Length == 0 && attachments.Count == 0))
|
|
throw new HostError("chat_text_invalid", "聊天内容为空或超过 2000 个字符。");
|
|
string? trustedContext = chatRequest.TrustedContext;
|
|
if (trustedContext is not null
|
|
&& !TrustedDiagnosticContextStore.IsWellFormedPrompt(trustedContext))
|
|
throw new HostError(
|
|
"trusted_context_invalid",
|
|
"本机 ERP 诊断上下文格式无效。");
|
|
if (attachments.Count > _configuration.MaximumAttachmentCount)
|
|
throw new HostError("attachment_count_exceeded", "聊天附件数量超过允许范围。");
|
|
object message = BuildMessage(text, attachments, trustedContext);
|
|
|
|
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeout.CancelAfter(_configuration.ChatTimeout);
|
|
using HttpRequestMessage request = new(
|
|
HttpMethod.Post,
|
|
new Uri(_configuration.AstrBotBaseUri, "api/v1/chat"));
|
|
request.Headers.TryAddWithoutValidation("X-API-Key", _configuration.AstrBotApiKey);
|
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
|
request.Content = JsonContent.Create(new
|
|
{
|
|
session_id = _configuration.AstrBotSessionId,
|
|
message,
|
|
flags = new
|
|
{
|
|
enable_inline_genui = false,
|
|
enable_default_system_prompt = true,
|
|
enable_streaming = true
|
|
}
|
|
});
|
|
|
|
using HttpResponseMessage response = await _httpClient.SendAsync(
|
|
request,
|
|
HttpCompletionOption.ResponseHeadersRead,
|
|
timeout.Token).ConfigureAwait(false);
|
|
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
|
throw new HostError("astrbot_auth_failed", "AstrBot API Key 无效或缺少 chat scope。");
|
|
if (!response.IsSuccessStatusCode)
|
|
throw new HostError("astrbot_http_error", "AstrBot 对话服务暂时不可用。");
|
|
string mediaType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
|
|
if (!string.Equals(mediaType, "text/event-stream", StringComparison.OrdinalIgnoreCase))
|
|
throw new HostError("astrbot_protocol_error", "AstrBot 未返回 SSE 对话流。");
|
|
|
|
await using Stream stream = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false);
|
|
bool sessionBound = false;
|
|
string? boundRunId = null;
|
|
await foreach (SseFrame frame in SseParser.ReadAsync(stream, timeout.Token).ConfigureAwait(false))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(frame.Data)) continue;
|
|
JsonDocument document;
|
|
try
|
|
{
|
|
document = JsonDocument.Parse(frame.Data, new JsonDocumentOptions
|
|
{
|
|
AllowTrailingCommas = false,
|
|
CommentHandling = JsonCommentHandling.Disallow,
|
|
MaxDepth = 64
|
|
});
|
|
}
|
|
catch (JsonException error)
|
|
{
|
|
throw new HostError("astrbot_protocol_error", "AstrBot SSE 包含无效 JSON。", error);
|
|
}
|
|
using (document)
|
|
{
|
|
JsonElement root = document.RootElement;
|
|
if (root.ValueKind != JsonValueKind.Object)
|
|
throw new HostError("astrbot_protocol_error", "AstrBot SSE 事件必须是 JSON 对象。");
|
|
EnsureUniqueProperties(root);
|
|
string type = RequiredProtocolString(root, "type", 64);
|
|
if (!SafeType.IsMatch(type))
|
|
throw new HostError("astrbot_protocol_error", "AstrBot SSE 事件类型无效。");
|
|
string? chainType = OptionalProtocolString(root, "chain_type", 64);
|
|
string? sessionId = OptionalProtocolString(root, "session_id", 256);
|
|
string? messageId = OptionalProtocolString(root, "message_id", 128);
|
|
string? legacyRunId = OptionalProtocolString(root, "run_id", 128);
|
|
string? runId = ResolveRunId(messageId, legacyRunId);
|
|
|
|
if (!sessionBound)
|
|
{
|
|
if (!string.Equals(type, "session_id", StringComparison.Ordinal))
|
|
throw new HostError(
|
|
"astrbot_session_binding_required",
|
|
"AstrBot SSE 未先回显本轮会话标识。");
|
|
ValidateSessionId(sessionId);
|
|
sessionBound = true;
|
|
}
|
|
else if (sessionId is not null)
|
|
{
|
|
ValidateSessionId(sessionId);
|
|
}
|
|
|
|
if (runId is not null)
|
|
{
|
|
if (!SafeRunId.IsMatch(runId))
|
|
throw new HostError(
|
|
"astrbot_protocol_error",
|
|
"AstrBot SSE 运行标识格式无效。");
|
|
if (boundRunId is null)
|
|
boundRunId = runId;
|
|
else if (!string.Equals(boundRunId, runId, StringComparison.Ordinal))
|
|
throw new HostError(
|
|
"astrbot_run_mismatch",
|
|
"AstrBot SSE 混入了另一轮运行事件。");
|
|
}
|
|
|
|
if (RequiresRunBinding(type, chainType) && runId is null)
|
|
throw new HostError(
|
|
"astrbot_run_binding_required",
|
|
"AstrBot SSE 关键事件缺少本轮运行标识。");
|
|
JsonElement data = root.TryGetProperty("data", out JsonElement value)
|
|
? value.Clone()
|
|
: JsonSerializer.SerializeToElement<object?>(null);
|
|
yield return new AstrBotStreamEvent(
|
|
type,
|
|
data,
|
|
chainType,
|
|
Boolean(root, "streaming"),
|
|
runId,
|
|
sessionId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ValidateSessionId(string? sessionId)
|
|
{
|
|
if (!string.Equals(
|
|
sessionId,
|
|
_configuration.AstrBotSessionId,
|
|
StringComparison.Ordinal))
|
|
throw new HostError(
|
|
"astrbot_session_mismatch",
|
|
"AstrBot SSE 会话标识与请求不一致。");
|
|
}
|
|
|
|
private static string? ResolveRunId(string? messageId, string? legacyRunId)
|
|
{
|
|
if (messageId is not null
|
|
&& legacyRunId is not null
|
|
&& !string.Equals(messageId, legacyRunId, StringComparison.Ordinal))
|
|
throw new HostError(
|
|
"astrbot_run_mismatch",
|
|
"AstrBot SSE 同时返回了冲突的运行标识。");
|
|
return messageId ?? legacyRunId;
|
|
}
|
|
|
|
private static bool RequiresRunBinding(string type, string? chainType)
|
|
{
|
|
return string.Equals(type, "end", StringComparison.Ordinal)
|
|
|| string.Equals(chainType, "tool_call", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(chainType, "tool_call_result", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static void EnsureUniqueProperties(JsonElement value)
|
|
{
|
|
HashSet<string> names = new(StringComparer.Ordinal);
|
|
foreach (JsonProperty property in value.EnumerateObject())
|
|
{
|
|
if (!names.Add(property.Name))
|
|
throw new HostError(
|
|
"astrbot_protocol_error",
|
|
"AstrBot SSE 事件包含重复字段。");
|
|
}
|
|
}
|
|
|
|
private static string RequiredProtocolString(
|
|
JsonElement value,
|
|
string name,
|
|
int maximumLength)
|
|
{
|
|
return OptionalProtocolString(value, name, maximumLength)
|
|
?? throw new HostError(
|
|
"astrbot_protocol_error",
|
|
"AstrBot SSE 事件缺少必要字段。");
|
|
}
|
|
|
|
private static string? OptionalProtocolString(
|
|
JsonElement value,
|
|
string name,
|
|
int maximumLength)
|
|
{
|
|
if (!value.TryGetProperty(name, out JsonElement item)) return null;
|
|
if (item.ValueKind != JsonValueKind.String)
|
|
throw new HostError(
|
|
"astrbot_protocol_error",
|
|
"AstrBot SSE 身份字段类型无效。");
|
|
string result = item.GetString() ?? string.Empty;
|
|
if (result.Length is < 1
|
|
|| result.Length > maximumLength
|
|
|| result.Any(char.IsControl)
|
|
|| !string.Equals(result, result.Trim(), StringComparison.Ordinal))
|
|
throw new HostError(
|
|
"astrbot_protocol_error",
|
|
"AstrBot SSE 身份字段内容无效。");
|
|
return result;
|
|
}
|
|
|
|
private static bool Boolean(JsonElement value, string name)
|
|
{
|
|
return value.TryGetProperty(name, out JsonElement item)
|
|
&& item.ValueKind is JsonValueKind.True;
|
|
}
|
|
|
|
private static object BuildMessage(
|
|
string text,
|
|
IReadOnlyList<AstrBotChatAttachment> attachments,
|
|
string? trustedContext)
|
|
{
|
|
if (attachments.Count == 0 && trustedContext is null) return text;
|
|
List<object> parts = new();
|
|
if (text.Length > 0) parts.Add(new { type = "plain", text });
|
|
if (trustedContext is not null)
|
|
parts.Add(new { type = "plain", text = trustedContext });
|
|
foreach (AstrBotChatAttachment attachment in attachments)
|
|
{
|
|
string fileName = (attachment.FileName ?? string.Empty).Trim();
|
|
if (!SafeAttachmentId.IsMatch(attachment.AttachmentId ?? string.Empty)
|
|
|| (attachment.Type != "image" && attachment.Type != "file")
|
|
|| fileName.Length is < 1 or > 128
|
|
|| fileName.Any(char.IsControl)
|
|
|| fileName.Contains('/')
|
|
|| fileName.Contains('\\'))
|
|
throw new HostError("attachment_invalid", "聊天附件字段无效。");
|
|
parts.Add(new
|
|
{
|
|
type = attachment.Type,
|
|
attachment_id = attachment.AttachmentId,
|
|
filename = fileName
|
|
});
|
|
}
|
|
return parts;
|
|
}
|
|
}
|