Files
Shirobot.Plugin.MyAgent/AgentManagementServer.cs

451 lines
19 KiB
C#

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<ShirobotOptions>(options => options.DataDir = Path.Combine(context.PluginDirectory, "data"));
builder.Services.AddAgentCore();
builder.Services.AddSingleton<IGroupPlatform>(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<ShirobotOptions> 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<GroupChatEvent> MergeRecentEvents(IReadOnlyList<GroupChatEvent> remote, IReadOnlyList<GroupChatEvent> local, int limit)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var merged = new List<GroupChatEvent>();
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];
}
}
}