451 lines
19 KiB
C#
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];
|
|
}
|
|
|
|
}
|
|
}
|