feat: add ERP agent pet bridge and startup guide
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user