feat: add ERP agent pet bridge and startup guide

This commit is contained in:
郎速科技
2026-08-14 14:28:28 +08:00
parent a803070819
commit 4c08f4c948
252 changed files with 134752 additions and 72 deletions
@@ -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;
}
}