using System.Text.Json; using Microsoft.Extensions.Options; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using MyAgent; using MyAgent.Agents; using MyAgent.Core; using MyAgent.Storage; using MyAgent.Tools; namespace Shirobot.Plugin.MyAgent; public static class AgentManagementServer { public static WebApplication Build(ShiroBot.SDK.Plugin.IBotContext context, string listenUrl) { if (!Uri.TryCreate(listenUrl, UriKind.Absolute, out var listenUri) || listenUri.Scheme != Uri.UriSchemeHttp || !listenUri.IsLoopback) throw new ArgumentException("ManagementListenUrl must be a loopback HTTP URL.", nameof(listenUrl)); var webRoot = Path.Combine(Path.GetDirectoryName(typeof(AgentPlugin).Assembly.Location)!, "wwwroot"); var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = context.PluginDirectory, WebRootPath = webRoot }); builder.WebHost.UseUrls(listenUrl); builder.Services.Configure(options => options.DataDir = Path.Combine(context.PluginDirectory, "data")); builder.Services.AddAgentCore(); builder.Services.AddSingleton(new ShirobotGroupPlatform(context)); builder.Services.AddCors(o => o.AddDefaultPolicy(p => p .WithOrigins("http://127.0.0.1:5173", "http://localhost:5173", "http://127.0.0.1:5174", "http://localhost:5174") .AllowAnyHeader().AllowAnyMethod())); var app = builder.Build(); app.UseCors(); app.UseDefaultFiles(); app.UseStaticFiles(); var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }; var sseJsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); app.MapGet("/api/health", async (LocalConfigStore localStore, CancellationToken ct) => { var local = await localStore.ReadAsync(ct); return Results.Json(new { ok = true, modelConfigured = !string.IsNullOrWhiteSpace(local.OpenAi.ApiKey), exaConfigured = !string.IsNullOrWhiteSpace(local.Exa.ApiKey), now = DateTimeOffset.Now }, jsonOptions); }); app.MapGet("/api/config", async (IOptions options, LocalConfigStore localStore, CancellationToken ct) => { var cfg = options.Value; var local = await localStore.ReadAsync(ct); return Results.Json(new { dataDir = cfg.DataDir, configPath = localStore.GetConfigPath(), openAi = new { baseUrl = local.OpenAi.BaseUrl, model = local.OpenAi.Model, routerModel = local.OpenAi.RouterModel, toolModel = local.OpenAi.ToolModel, memoryModel = local.OpenAi.MemoryModel, replyModel = local.OpenAi.ReplyModel, embeddingModel = local.OpenAi.EmbeddingModel, apiKey = "", apiKeyConfigured = !string.IsNullOrWhiteSpace(local.OpenAi.ApiKey) }, exa = new { enabled = local.Exa.Enabled, maxResults = local.Exa.MaxResults, mode = local.Exa.Mode, apiKey = "", apiKeyConfigured = !string.IsNullOrWhiteSpace(local.Exa.ApiKey) }, search = new { webSearchProvider = local.Search.WebSearchProvider, tavilyApiKey = "", tavilyApiKeyConfigured = !string.IsNullOrWhiteSpace(local.Search.TavilyApiKey) }, media = new { imageSearchProvider = local.Media.ImageSearchProvider, braveApiKey = "", braveApiKeyConfigured = !string.IsNullOrWhiteSpace(local.Media.BraveApiKey) }, bot = new { botUserId = local.Bot.BotUserId, botDisplayName = local.Bot.BotDisplayName, botAliases = local.Bot.BotAliases, replyOnlyWhenMentioned = local.Bot.ReplyOnlyWhenMentioned, defaultGroupId = local.Bot.DefaultGroupId, allowedGroupIds = local.Bot.AllowedGroupIds }, fusion = cfg.Fusion, context = cfg.Context, memory = cfg.Memory, runtime = cfg.Runtime, webCache = cfg.WebCache, note = "敏感字段不会通过 API 返回;密码框留空表示保留已配置值。" }, jsonOptions); }); app.MapPut("/api/config", async (LocalConfigStore localStore, RuntimeConfig config, CancellationToken ct) => { var saved = await localStore.WriteAsync(config, ct); return Results.Json(new { ok = true, configPath = localStore.GetConfigPath(), openAi = new { baseUrl = saved.OpenAi.BaseUrl, model = saved.OpenAi.Model, routerModel = saved.OpenAi.RouterModel, toolModel = saved.OpenAi.ToolModel, memoryModel = saved.OpenAi.MemoryModel, replyModel = saved.OpenAi.ReplyModel, apiKey = "", apiKeyConfigured = !string.IsNullOrWhiteSpace(saved.OpenAi.ApiKey) }, exa = new { enabled = saved.Exa.Enabled, maxResults = saved.Exa.MaxResults, mode = saved.Exa.Mode, apiKey = "", apiKeyConfigured = !string.IsNullOrWhiteSpace(saved.Exa.ApiKey) }, search = new { webSearchProvider = saved.Search.WebSearchProvider, tavilyApiKey = "", tavilyApiKeyConfigured = !string.IsNullOrWhiteSpace(saved.Search.TavilyApiKey) }, media = new { imageSearchProvider = saved.Media.ImageSearchProvider, braveApiKey = "", braveApiKeyConfigured = !string.IsNullOrWhiteSpace(saved.Media.BraveApiKey) }, bot = new { botUserId = saved.Bot.BotUserId, botDisplayName = saved.Bot.BotDisplayName, botAliases = saved.Bot.BotAliases, replyOnlyWhenMentioned = saved.Bot.ReplyOnlyWhenMentioned, defaultGroupId = saved.Bot.DefaultGroupId, allowedGroupIds = saved.Bot.AllowedGroupIds } }, jsonOptions); }); app.MapGet("/api/config/ui-metadata", (ConfigUiMetadata metadata) => Results.Json(metadata, jsonOptions)); app.MapGet("/api/openai/models", async (OpenAiModelService modelService, string? baseUrl, string? apiKey, CancellationToken ct) => { var result = await modelService.ListAsync(baseUrl, apiKey, ct); return Results.Json(result, jsonOptions); }); app.MapPost("/api/openai/models", async (OpenAiModelService modelService, OpenAiModelsRequest request, CancellationToken ct) => { var result = await modelService.ListAsync(request.BaseUrl, request.ApiKey, ct); return Results.Json(result, jsonOptions); }); app.MapGet("/api/group-chat/profile", async (IGroupPlatform platform, LocalConfigStore configStore, long? groupId, CancellationToken ct) => { var gid = groupId ?? (await configStore.ReadAsync(ct)).Bot.DefaultGroupId; if (gid <= 0) return Results.BadRequest("groupId is required"); var profile = await platform.GetProfileAsync(gid, ct); return Results.Json(profile, jsonOptions); }); app.MapGet("/api/group-chat/recent", async (GroupChatHub hub, IGroupPlatform platform, RecentLogStore recentLog, LocalConfigStore configStore, GroupHistorySyncService historySync, ConversationRegistry conversations, long? groupId, int? limit, CancellationToken ct) => { _ = await historySync.SyncOnceAsync(ct); var cfg = await configStore.ReadAsync(ct); var gid = groupId ?? cfg.Bot.DefaultGroupId; if (gid <= 0) return Results.BadRequest("groupId is required"); var groupKey = gid.ToString(); var take = Math.Clamp(limit ?? 50, 1, 100); var history = await platform.GetHistoryAsync(gid, take, ct); var localMessages = await recentLog.ReadRecentAsync(groupKey, take * 3, ct); var convs = await conversations.ListAsync(groupKey, ct); var turns = convs.SelectMany(c => c.Turns.Select(t => new { Conversation = c, Turn = t })).ToList(); var localEvents = localMessages .Select(m => { if (m.UserId != "assistant:shiro") return GroupChatEvent.User(gid, m.UserId, m.UserId, m.Text, m.CreatedAt, m.Attachments.ToArray(), m.MessageId); var matched = turns .Where(x => !string.IsNullOrWhiteSpace(x.Turn.AssistantText) && NormalizeEventText(x.Turn.AssistantText) == NormalizeEventText(m.Text)) .OrderBy(x => Math.Abs((x.Turn.CreatedAt - m.CreatedAt).TotalSeconds)) .FirstOrDefault(); var ev = GroupChatEvent.BotMessage( gid, cfg.Bot.BotDisplayName, m.Text, m.CreatedAt, matched?.Turn.ElapsedMs, null, m.Attachments.ToArray(), taskId: matched?.Turn.TaskId ?? "", threadId: matched?.Conversation.ThreadId ?? ""); if (matched is not null) ev.Trace = matched.Turn.Trace; return ev; }) .ToList(); var merged = MergeRecentEvents(history, localEvents, take); if (merged.Count > 0) return Results.Json(merged, jsonOptions); return Results.Json(hub.Recent(gid), jsonOptions); }); app.MapGet("/api/group-chat/events", async (HttpResponse response, GroupChatHub hub, long? groupId, CancellationToken ct) => { response.Headers.ContentType = "text/event-stream; charset=utf-8"; response.Headers.CacheControl = "no-cache"; await foreach (var ev in hub.SubscribeAsync(ct)) { if (groupId is not null && ev.GroupId != 0 && ev.GroupId != groupId.Value) continue; await response.WriteAsync($"event: {ev.Type}\ndata: {JsonSerializer.Serialize(ev, sseJsonOptions)}\n\n", ct); await response.Body.FlushAsync(ct); } }); app.MapGet("/api/workflow/metadata", (WorkflowUiMetadata metadata) => Results.Json(metadata, jsonOptions)); app.MapGet("/api/tools/catalog", (ToolCatalog catalog) => Results.Json(catalog.List(), jsonOptions)); app.MapGet("/api/media/sources", (MediaService media) => Results.Json(media.GetSources(), jsonOptions)); app.MapPost("/api/media/search", async (MediaService media, MediaSearchRequest request, CancellationToken ct) => { var result = await media.SearchImagesAsync(request, ct); return Results.Json(result, jsonOptions); }); app.MapPost("/api/media/download", async (MediaService media, MediaDownloadRequest request, CancellationToken ct) => { var result = await media.DownloadAsync(request, ct); return string.IsNullOrWhiteSpace(result.Error) ? Results.Json(result, jsonOptions) : Results.BadRequest(result); }); app.MapGet("/api/media/{mediaId}", async (MediaService media, string mediaId, CancellationToken ct) => { var file = await media.TryGetFileAsync(mediaId, ct); return file is null ? Results.NotFound() : Results.File(file.Value.Path, file.Value.Mime); }); app.MapGet("/api/files/{groupId}/{fileId}", (string groupId, string fileId) => { var dir = Path.Combine(AppContext.BaseDirectory, "data", "files", groupId); if (!Directory.Exists(dir)) return Results.NotFound(); var file = Directory.EnumerateFiles(dir, fileId + ".*").FirstOrDefault(); if (file is null) return Results.NotFound(); return Results.File(file, "application/octet-stream", Path.GetFileName(file)); }); app.MapGet("/api/groups/{groupId}/context", async (GroupContextStore store, string groupId, CancellationToken ct) => { var md = await store.ReadOrCreateAsync(groupId, ct); return Results.Text(md, "text/markdown; charset=utf-8"); }); app.MapPut("/api/groups/{groupId}/context", async (GroupContextStore store, string groupId, HttpRequest request, CancellationToken ct) => { using var reader = new StreamReader(request.Body); var md = await reader.ReadToEndAsync(ct); await store.WriteAsync(groupId, md, ct); return Results.Json(new { ok = true }); }); app.MapGet("/api/groups/{groupId}/members", async (GroupContextStore store, string groupId, CancellationToken ct) => { var members = await store.ListMembersAsync(groupId, ct); return Results.Json(members, jsonOptions); }); app.MapGet("/api/groups/{groupId}/members/{userId}/memory", async (GroupContextStore store, string groupId, string userId, CancellationToken ct) => { var md = await store.ReadOrCreateMemberAsync(groupId, userId, ct); return Results.Text(md, "text/markdown; charset=utf-8"); }); app.MapPut("/api/groups/{groupId}/members/{userId}/memory", async (GroupContextStore store, string groupId, string userId, HttpRequest request, CancellationToken ct) => { using var reader = new StreamReader(request.Body); var md = await reader.ReadToEndAsync(ct); await store.WriteMemberAsync(groupId, userId, md, ct); return Results.Json(new { ok = true }); }); app.MapPost("/api/groups/{groupId}/members/{userId}/memory/reset", async (GroupContextStore store, string groupId, string userId, CancellationToken ct) => { var md = await store.ResetMemberAsync(groupId, userId, ct); return Results.Json(new { ok = true, markdown = md, message = "群友记忆已清理并重置,旧文件已备份" }, jsonOptions); }); app.MapDelete("/api/groups/{groupId}/members/{userId}/memory", async (GroupContextStore store, string groupId, string userId, CancellationToken ct) => { var deleted = await store.DeleteMemberAsync(groupId, userId, backup: true, ct); return Results.Json(new { ok = true, deleted, message = deleted ? "群友记忆已删除,旧文件已备份" : "群友记忆不存在" }, jsonOptions); }); app.MapPost("/api/groups/{groupId}/context/reset", async (GroupContextStore store, string groupId, CancellationToken ct) => { var md = await store.ResetAsync(groupId, ct); return Results.Json(new { ok = true, markdown = md, message = "context.md 已清理并重置为默认模板,旧文件已备份" }, jsonOptions); }); app.MapPost("/api/groups/{groupId}/context/clear-all", async (GroupContextStore store, RecentLogStore recentLog, ConversationRegistry conversations, SessionManager sessions, GroupChatHub hub, string groupId, CancellationToken ct) => { var result = await store.ClearAllContextAsync(groupId, ct); await conversations.ClearGroupAsync(groupId, ct); await sessions.ClearGroupAsync(groupId, ct); var recentDeleted = await recentLog.ClearAsync(groupId, ct); if (recentDeleted) result.Deleted = result.Deleted.Concat(["recent.jsonl"]).ToList(); if (long.TryParse(groupId, out var gid)) hub.ClearGroup(gid); return Results.Json(new { ok = true, result.GroupId, markdown = result.Markdown, result.Deleted, message = result.Message + ";线程上下文、待融合消息、最近消息和页面事件缓存已清空" }, jsonOptions); }); app.MapPost("/api/groups/{groupId}/context/refresh", async (MemoryWorker memory, string groupId, CancellationToken ct) => { var result = await memory.RefreshAsync(groupId, ct); return Results.Json(result, jsonOptions); }); app.MapGet("/api/groups/{groupId}/recent", async (RecentLogStore store, string groupId, int? limit, CancellationToken ct) => { var items = await store.ReadRecentAsync(groupId, limit ?? 50, ct); return Results.Json(items, jsonOptions); }); app.MapGet("/api/groups/{groupId}/context-parts", async (ContextBuilder contextBuilder, string groupId, string? userId, CancellationToken ct) => { var parts = await contextBuilder.BuildPartsAsync(groupId, string.IsNullOrWhiteSpace(userId) ? "demo-user" : userId, ct); return Results.Json(parts, jsonOptions); }); app.MapGet("/api/groups/{groupId}/heavy-memory", async (HeavyMemoryStore store, string groupId, int? limit, CancellationToken ct) => { var items = (await store.ReadAllAsync(groupId, ct)) .OrderByDescending(x => x.EndAt) .Take(Math.Clamp(limit ?? 50, 1, 200)) .Select(ToHeavyMemoryDto) .ToList(); return Results.Json(new { ok = true, path = store.GetHeavyMemoryPath(groupId), backupDir = store.GetHeavyMemoryBackupDir(groupId), lightRecallCachePath = store.GetLightRecallCachePath(groupId), items }, jsonOptions); }); app.MapGet("/api/groups/{groupId}/heavy-memory/search", async (HeavyMemoryStore store, string groupId, string? q, int? limit, CancellationToken ct) => { var items = string.IsNullOrWhiteSpace(q) ? [] : (await store.SearchAsync(groupId, q, Math.Clamp(limit ?? 8, 1, 20), ct)).Select(ToHeavyMemoryDto).ToList(); return Results.Json(new { ok = true, query = q ?? "", items }, jsonOptions); }); app.MapGet("/api/groups/{groupId}/light-recall-cache", async (HeavyMemoryStore store, string groupId, CancellationToken ct) => { var md = await store.ReadLightRecallCacheAsync(groupId, ct); return Results.Text(string.IsNullOrWhiteSpace(md) ? "" : md, "text/markdown; charset=utf-8"); }); static object ToHeavyMemoryDto(HeavyMemoryEntry x) => new { x.Id, x.GroupId, x.Topic, x.Summary, x.StartAt, x.EndAt, x.Participants, x.Keywords, x.SourceMessageIds, x.Importance, x.CreatedAt, RawDialoguePreview = string.IsNullOrWhiteSpace(x.RawDialogue) ? "" : x.RawDialogue.Length <= 1200 ? x.RawDialogue : x.RawDialogue[..1200] + "\n...(原始对话过长,已在前端预览截断)" }; app.MapGet("/api/conversations", async (ConversationRegistry conversations, AgentTopologyBuilder topology, string? groupId, CancellationToken ct) => { var items = await conversations.ListAsync(groupId, ct); foreach (var item in items) { item.LastTopology = topology.Build(item.LastTrace, item.LastTurnStatus); foreach (var turn in item.Turns) { var turnStatus = item.LastTaskId == turn.TaskId ? item.LastTurnStatus : null; turn.Topology = topology.Build(turn.Trace, turnStatus); } } return Results.Json(items, jsonOptions); }); app.MapGet("/api/tasks", (TaskRegistry tasks) => Results.Json(tasks.List(), jsonOptions)); app.MapGet("/api/tasks/{taskId}", (TaskRegistry tasks, string taskId) => tasks.TryGet(taskId, out var task) ? Results.Json(task, jsonOptions) : Results.NotFound()); app.MapGet("/api/groups/{groupId}/runs/{runId}", async (AgentRunStore runs, string groupId, string runId, CancellationToken ct) => { var state = await runs.TryReadAsync(groupId, runId, ct); return state is null ? Results.NotFound() : Results.Json(state, jsonOptions); }); app.MapFallbackToFile("index.html"); return app; static IReadOnlyList MergeRecentEvents(IReadOnlyList remote, IReadOnlyList local, int limit) { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var merged = new List(); foreach (var ev in remote.Concat(local).OrderBy(x => x.At)) { var key = !string.IsNullOrWhiteSpace(ev.MessageId) ? "msg:" + ev.MessageId : $"{ev.Type}:{ev.SenderId}:{ev.At.ToUnixTimeSeconds()}:{NormalizeEventText(ev.Text)}"; if (!seen.Add(key)) continue; merged.Add(ev); } return merged.TakeLast(Math.Max(1, limit)).ToList(); } static string NormalizeEventText(string text) { text = text.Replace("\r\n", "\n").Trim(); return text.Length <= 80 ? text : text[..80]; } } }